#!/usr/bin/env python3
"""
Certbot --deploy-hook script: pushes a certificate issued by an external ACME client (Certbot)
into a FortiGate's certificate store over SSH, using FortiGate's plain certificate-import CLI -
this is the alternative to FortiGate's own native ACME client described elsewhere in the
TrustView automation documentation ("Use FortiGate's native ACME client..."). Use this instead
when you need a wildcard or multi-SAN certificate, need DNS-01 validation (so the FortiGate
itself never has to expose port 80), or want to push one certificate to several FortiGates from
a single renewal.

Run by Certbot after a successful issuance or renewal. Reads the certificate and key from
$RENEWED_LINEAGE (which Certbot sets for every --deploy-hook invocation) unless --cert-file and
--key-file are given explicitly.

Requires: the OpenSSH client ("ssh") on PATH, and SSH access to the FortiGate's admin account.
Key-based authentication (--ssh-key) is strongly recommended; --ssh-password is supported for
completeness but needs "sshpass" installed and puts the password in the environment of a
scheduled job, which is worse practice than a dedicated SSH key.

Code-reviewed and syntax/logic-tested (including a mocked SSH session covering both the success
and failure paths - see the TrustView automation documentation for details), but not run against
a live FortiGate - verify in a non-production environment before relying on it for certificate
renewals.
"""
import argparse
import os
import subprocess
import sys


def read_pem(path):
    with open(path, "r", encoding="utf-8") as f:
        content = f.read().strip()
    if "BEGIN" not in content:
        raise SystemExit(f"'{path}' does not look like a PEM file (no BEGIN marker found).")
    return content


def build_cli_block(cert_name, cert_pem, key_pem, bind_admin_gui, bind_ssl_vpn):
    lines = [
        "config vpn certificate local",
        f'edit "{cert_name}"',
        f'set private-key "{key_pem}"',
        f'set certificate "{cert_pem}"',
        "next",
        "end",
    ]
    if bind_admin_gui:
        lines += ['config system global', f'set admin-server-cert "{cert_name}"', "end"]
    if bind_ssl_vpn:
        lines += ['config vpn ssl settings', f'set servercert "{cert_name}"', "end"]
    return "\n".join(lines) + "\n"


def run_ssh(host, port, user, cli_block, ssh_key=None, ssh_password=None):
    ssh_cmd = ["ssh", "-p", str(port)]
    if ssh_key:
        ssh_cmd += ["-i", ssh_key]
    ssh_cmd += ["-o", "StrictHostKeyChecking=accept-new", f"{user}@{host}"]

    env = os.environ.copy()
    if ssh_password:
        # Keeps the password out of argv (visible via `ps`); requires sshpass to be installed.
        ssh_cmd = ["sshpass", "-e"] + ssh_cmd
        env["SSHPASS"] = ssh_password

    return subprocess.run(
        ssh_cmd,
        input=cli_block,
        capture_output=True,
        text=True,
        env=env,
        timeout=60,
    )


def main(argv=None):
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--host", required=True, help="FortiGate management IP or hostname")
    parser.add_argument("--port", type=int, default=22, help="SSH port (default: 22)")
    parser.add_argument("--user", required=True, help="FortiGate admin username")
    parser.add_argument("--cert-name", required=True, help="Name to give the certificate object in FortiGate's certificate store")
    parser.add_argument("--cert-file", help="Path to the certificate/chain (defaults to $RENEWED_LINEAGE/fullchain.pem)")
    parser.add_argument("--key-file", help="Path to the private key (defaults to $RENEWED_LINEAGE/privkey.pem)")
    parser.add_argument("--ssh-key", help="Path to an SSH private key for authentication (recommended)")
    parser.add_argument("--ssh-password", help="SSH password (requires sshpass); prefer --ssh-key instead")
    parser.add_argument("--bind-admin-gui", action="store_true", help="Also bind the certificate to the FortiGate admin GUI")
    parser.add_argument("--bind-ssl-vpn", action="store_true", help="Also bind the certificate to the SSL-VPN portal")
    args = parser.parse_args(argv)

    lineage = os.environ.get("RENEWED_LINEAGE")
    cert_file = args.cert_file or (os.path.join(lineage, "fullchain.pem") if lineage else None)
    key_file = args.key_file or (os.path.join(lineage, "privkey.pem") if lineage else None)

    if not cert_file or not key_file:
        raise SystemExit(
            "No certificate/key path given, and $RENEWED_LINEAGE is not set - pass --cert-file "
            "and --key-file explicitly, or run this script from a Certbot --deploy-hook."
        )

    cert_pem = read_pem(cert_file)
    key_pem = read_pem(key_file)
    cli_block = build_cli_block(args.cert_name, cert_pem, key_pem, args.bind_admin_gui, args.bind_ssl_vpn)

    if not args.ssh_key and not args.ssh_password:
        raise SystemExit("Pass either --ssh-key (recommended) or --ssh-password for authentication.")

    result = run_ssh(args.host, args.port, args.user, cli_block, ssh_key=args.ssh_key, ssh_password=args.ssh_password)

    output = (result.stdout or "") + (result.stderr or "")
    if result.returncode != 0 or "Command fail" in output:
        print(output, file=sys.stderr)
        raise SystemExit(f"FortiGate rejected part of the configuration (ssh exit code {result.returncode}). See output above.")

    print(f"Certificate '{args.cert_name}' imported into FortiGate {args.host} successfully.")
    if args.bind_admin_gui:
        print("Bound to the admin GUI.")
    if args.bind_ssl_vpn:
        print("Bound to the SSL-VPN portal.")


if __name__ == "__main__":
    main()
