#!/usr/bin/env python3
"""boneio-proxy-config — writes the packaged Caddy's configuration.

``caddy.service`` runs it as root in ``ExecStartPre``, ``ExecStartPost`` and
``ExecReload``; Caddy itself reads only what it leaves in /run/boneio-proxy. The Caddyfile is
the one ``init-certs.sh`` and ``init-certs-cloud.sh`` write for the proxy
container — the same internal authority and lifetimes, redirect, starting page
and routes — with the upstreams on the loopback and the admin API on a socket.
Caddy shares the host's network, so a TCP admin endpoint would let any local
process reconfigure the proxy.

The inputs are what boneIO already keeps, and most of them are written by the
unprivileged ``boneio`` account: the compose ``.env`` (ports), an uploaded
certificate pair, and the cloud's wildcard pair. Root reading them is the risk
this file is shaped around:

  * **A port is a number or the default.** Nothing from ``.env`` reaches the
    Caddyfile except an integer in range, so it cannot add a directive.
  * **A certificate is a small regular file reached without a symlink,** or it
    is not used. Every component of its path is opened with ``O_NOFOLLOW``,
    the file must be a regular file with one name, at most 64 KiB, and look
    like the PEM it claims to be. Otherwise ``privkey.pem -> /etc/shadow``
    would have root copy a secret to where the ``caddy`` user can read it.
  * **Nothing it reads becomes a path or a command.** Every path here is a
    constant, and the only command is Caddy's own ``validate`` under --check.

Writes into /run/boneio-proxy, which belongs to the ``caddy`` user, go the same
way: relative to a directory opened without following links, created
exclusively, then renamed into place. A compromised Caddy cannot turn them
into root's writes somewhere else.

Usage:
    boneio-proxy-config               follow a hostname change, then write the
                                      configuration and export the root (start)
    boneio-proxy-config --reload      the same without the hostname: a reload
                                      never discards the authority under a
                                      running Caddy
    boneio-proxy-config --export-root only export the root, waiting briefly
                                      for Caddy to create it (after start)
    boneio-proxy-config --check       render to a temporary directory and have
                                      Caddy validate it; nothing else changes

Exit codes:
    0  written (or valid, under --check); always, under --export-root
    1  it could not be written, or Caddy rejected it
"""

from __future__ import annotations

import argparse
import contextlib
import grp
import logging
import os
import shutil
import socket
import ssl
import stat
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from string import Template
from typing import NamedTuple

_LOGGER = logging.getLogger("boneio-proxy-config")

#: The compose project's environment. The ports were always set here, for the
#: container, and the panel keeps setting them here.
ENV_FILE = Path("/home/boneio/docker/nodered/.env")
#: Where the panel stores an uploaded certificate. Kept where the container
#: read it, so nothing in the panel changes with the switch.
CUSTOM_DIR = Path("/home/boneio/docker/nodered/caddy/data/custom")
#: Where cloud registration stores the wildcard certificate.
CLOUD_DIR = Path("/home/boneio/docker/nodered/caddy/ssl")
#: Present when the device is registered with the cloud. Root-owned, so the
#: account that writes the certificate cannot also decide that it is served.
CLOUD_MARKER = Path("/etc/boneio/proxy-cloud")
#: caddy.service's RuntimeDirectory. Spelled /run, never /var/run: the latter
#: is a symlink, and every path here is walked without following one.
RUN_DIR = Path("/run/boneio-proxy")
#: Root-owned state: the last hostname, and the exported root certificate.
STATE_DIR = Path("/var/lib/boneio/proxy")
#: The package's data directory for the ``caddy`` user, holding its authority.
CADDY_DATA = Path("/var/lib/caddy/.local/share/caddy")
CADDY = "/usr/bin/caddy"
PAGE_DIR = "/usr/share/boneio/proxy"
#: How long --export-root waits for the authority. Caddy reports ready once its
#: PKI app has created it, so this only covers a slow disk.
EXPORT_WAIT = 10.0
ADMIN_SOCKET = "/run/boneio-proxy/admin.sock"

#: Larger than any real chain, small enough that a planted file cannot make a
#: root process read the disk.
MAX_PEM = 64 * 1024

_CERT_HEADER = b"-----BEGIN CERTIFICATE-----"


class Ports(NamedTuple):
    """The panel's own port, and the two the proxy listens on."""

    web: int = 8090
    http: int = 8091
    https: int = 8443


_PORT_NAMES = {"WEB_PORT": "web", "HTTP_PORT": "http", "HTTPS_PORT": "https"}
#: Node-RED's port on the loopback, which the proxy cannot also listen on.
NODERED_PORT = 1880


def read_ports(env_text: str) -> Ports:
    """The ports from a compose ``.env``, each a plain integer or its default.

    Read the way compose reads it closely enough for the three variables the
    panel writes: ``export`` and matching quotes are accepted, and the last
    assignment wins. Only ASCII digits are a number here — ``int()`` alone
    would also take ``+80``, ``8_0`` and other scripts' digits.

    The two ports Caddy listens on must also be bindable and free: the unit
    grants no capabilities, so nothing below 1024, and neither may be the
    other, the panel's or Node-RED's. A collision would stop Caddy starting.

    Args:
        env_text: The file's contents.

    Returns:
        The ports, with anything unusable replaced by the default.
    """
    found: dict[str, int] = {}
    for line in env_text.splitlines():
        line = line.strip()
        if line.startswith("export "):
            line = line[len("export "):].lstrip()
        name, sep, value = line.partition("=")
        field = _PORT_NAMES.get(name.strip())
        if not sep or field is None:
            continue
        value = value.strip()
        if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
            value = value[1:-1]
        # The length first: int() refuses more than 4300 digits with an error
        # this file does not expect.
        if len(value) <= 5 and value.isascii() and value.isdigit() and 1 <= int(value) <= 65535:
            found[field] = int(value)
        else:
            # The value itself is not logged: the file belongs to another
            # account, and what it holds is not root's to repeat.
            _LOGGER.warning("%s is not a port; using the default", name.strip())
            found.pop(field, None)
    asked = Ports(**found)
    http, https = asked.http, asked.https
    if http < 1024 or http in (asked.web, NODERED_PORT, asked.https):
        _LOGGER.warning("HTTP_PORT cannot be listened on; using the default")
        http = Ports().http
    # Against what was asked and what HTTP fell back to, so that neither
    # fallback lands on the other listener.
    if https < 1024 or https in (asked.web, NODERED_PORT, asked.http, http):
        _LOGGER.warning("HTTPS_PORT cannot be listened on; using the default")
        https = Ports().https
    return asked._replace(http=http, https=https)


def _open_nofollow(path: Path, flags: int) -> int:
    """Open *path* without following a symlink at any component.

    ``O_NOFOLLOW`` alone covers only the last one: a directory swapped for a
    link would still lead root wherever it points.

    Args:
        path: An absolute path.
        flags: Flags for the final component; ``O_NOFOLLOW`` is added.

    Returns:
        The open file descriptor.
    """
    parts = Path(path).parts
    fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
    try:
        for name in parts[1:-1]:
            child = os.open(
                name,
                os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
                dir_fd=fd,
            )
            os.close(fd)
            fd = child
        return os.open(parts[-1], flags | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=fd)
    finally:
        os.close(fd)


def _read_small(path: Path, limit: int = MAX_PEM) -> bytes | None:
    """The contents of a small regular file, or None with a warning.

    Non-blocking, because a FIFO in its place would otherwise hold the open —
    and Caddy's start with it — until systemd gives up. Everything is checked
    on the open descriptor, not the path, so nothing can be swapped in between,
    and the read stops one byte past the limit rather than trusting the size.

    Args:
        path: The file.
        limit: The most it may hold.

    Returns:
        Its bytes, or None when it is missing or unacceptable.
    """
    try:
        fd = _open_nofollow(path, os.O_RDONLY | os.O_NONBLOCK)
    except FileNotFoundError:
        return None
    except OSError as err:
        _LOGGER.warning("%s cannot be opened safely: %s", path, err.strerror)
        return None
    st = os.fstat(fd)
    if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1:
        os.close(fd)
        _LOGGER.warning("%s is not a plain file; ignored", path)
        return None
    with os.fdopen(fd, "rb") as handle:
        data = handle.read(limit + 1)
    if len(data) > limit:
        _LOGGER.warning("%s is larger than %d bytes; ignored", path, limit)
        return None
    return data


def _loads(pair: tuple[bytes, bytes]) -> bool:
    """Whether TLS can actually serve *pair*: whole, matching, unencrypted.

    The headers alone pass a key that belongs to another certificate, or a
    file cut short by a write that was still in progress — and Caddy refuses
    to start on either. Checked in a directory only root can read, before
    anything is copied where Caddy looks.
    """
    with tempfile.TemporaryDirectory(prefix="boneio-proxy-pair.") as scratch:
        cert, key = Path(scratch) / "cert.pem", Path(scratch) / "key.pem"
        cert.write_bytes(pair[0])
        key.write_bytes(pair[1])
        try:
            # An empty password makes an encrypted key fail instead of prompting.
            ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER).load_cert_chain(
                cert, key, password=b""
            )
        except (ssl.SSLError, ValueError):
            return False
    return True


def _is_cert(data: bytes) -> bool:
    return data.lstrip().startswith(_CERT_HEADER)


def read_pem_pair(cert: Path, key: Path) -> tuple[bytes, bytes] | None:
    """A certificate and its key, if both are safe to hand to Caddy.

    Args:
        cert: The certificate or chain.
        key: Its private key.

    Returns:
        Both files' bytes, or None when either is missing or unacceptable.
    """
    cert_pem, key_pem = _read_small(cert), _read_small(key)
    if cert_pem is None or key_pem is None:
        return None
    if not _is_cert(cert_pem):
        _LOGGER.warning("%s is not a PEM certificate; ignored", cert)
        return None
    # Not only the first block: openssl's EC keys open with their parameters.
    if not key_pem.lstrip().startswith(b"-----BEGIN") or b"PRIVATE KEY-----" not in key_pem:
        _LOGGER.warning("%s is not a PEM private key; ignored", key)
        return None
    return cert_pem, key_pem


#: What every HTTPS site serves: the starting page while the panel is down,
#: Node-RED's availability answered by the proxy itself, Node-RED, and the
#: panel. Copied from the container's scripts, with the upstreams on the
#: loopback — Node-RED publishes 1880 there, and the panel listens there.
_SITE = """\
        handle_errors {
                @502-504 expression {err.status_code} >= 502 && {err.status_code} <= 504
                handle @502-504 {
                        root * $pages
                        rewrite * /502.html
                        file_server
                }
        }

        handle /nodered-status {
                header Content-Type application/json
                header X-NodeRed-Available "true"
                header Access-Control-Expose-Headers "X-NodeRed-Available"
                respond `{"available": true}` 200
        }

        handle /nodered/* {
                reverse_proxy 127.0.0.1:1880 {
                        header_up X-Forwarded-Proto {scheme}
                        header_down X-NodeRed-Available "true"
                }
        }

        handle {
                reverse_proxy 127.0.0.1:$web {
                        header_up X-Forwarded-Proto {scheme}
                }
        }
"""

_CADDYFILE = """\
{
        # On a socket only root and caddy can reach, not Caddy's default TCP
        # port: the proxy shares the host's network, and every local process
        # could otherwise rewrite it.
        admin unix/$admin
        # The device's authority is trusted by people who choose to install
        # it, never by Caddy installing it into the system store.
        skip_install_trust
        # http_port must name the redirect site's port. Any other port Caddy
        # takes for HTTPS, and it then refuses the site as conflicting.
        http_port $http
        https_port $https
        # The device's own certificate authority issues for six months rather
        # than the twelve hours Caddy defaults to. Nobody trusts this authority
        # until they choose to, and when they do — by installing the root on
        # their own machines, or by clicking through once — a certificate that
        # expires the same evening undoes that daily. The intermediate has to
        # outlive the leaves it signs, or Caddy refuses to start.
        pki {
                ca local {
                        intermediate_lifetime 365d
                }
        }
}

# HTTP — redirect to HTTPS. Nothing is served in the clear.
:$http {
        redir https://{host}:$https{uri}
}
$cloud
# HTTPS for the hostname and any address the device is reached by.
https:// {
        $tls

$site}
"""

_CLOUD = """
# The cloud's wildcard certificate, for the PWA (*.black.boneio.app).
*.black.boneio.app {
        tls $tls_dir/cloud.crt $tls_dir/cloud.key

$site}
"""

#: The device's address is not known here and changes with the lease, so there
#: is no fixed name to ask for: the internal authority issues on demand.
_INTERNAL_TLS = """tls {
                issuer internal {
                        lifetime 180d
                }
                on_demand
        }"""


def render(
    ports: Ports, *, custom: bool, cloud: bool, tls_dir: Path = RUN_DIR / "tls"
) -> str:
    """The Caddyfile.

    Args:
        ports: Where the panel listens, and where the proxy does.
        custom: Serve the uploaded certificate rather than the device's own.
        cloud: Add the site for the cloud's wildcard certificate.
        tls_dir: Where the certificate copies are; only --check moves it.

    Returns:
        The configuration's text.
    """
    site = Template(_SITE).substitute(pages=PAGE_DIR, web=ports.web)
    tls = f"tls {tls_dir}/custom.crt {tls_dir}/custom.key" if custom else _INTERNAL_TLS
    cloud_block = (
        Template(_CLOUD).substitute(tls_dir=tls_dir, site=site) if cloud else ""
    )
    return Template(_CADDYFILE).substitute(
        admin=ADMIN_SOCKET,
        http=ports.http,
        https=ports.https,
        cloud=cloud_block,
        tls=tls,
        site=site,
    )


def _open_dir(path: Path, mode: int = 0o755) -> int:
    """Open a directory without following links, creating it if missing.

    Args:
        path: The directory.
        mode: Its mode if it has to be created.

    Returns:
        A descriptor for it.
    """
    flags = os.O_RDONLY | os.O_DIRECTORY
    with contextlib.suppress(FileNotFoundError):
        return _open_nofollow(path, flags)
    parent = _open_dir(path.parent, 0o755)
    try:
        created = True
        try:
            os.mkdir(path.name, mode, dir_fd=parent)
        except FileExistsError:
            created = False
        fd = os.open(path.name, flags | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent)
    finally:
        os.close(parent)
    if created:
        # caddy.service runs this under UMask=0077, which would leave the
        # exported root certificate in a directory nobody else can enter.
        os.fchmod(fd, mode)
    return fd


def _caddy_gid() -> int:
    try:
        return grp.getgrnam("caddy").gr_gid
    except KeyError:
        return 0


def _write(directory: int, name: str, data: bytes, mode: int, gid: int = 0) -> None:
    """Replace *name* in *directory* atomically, as root:*gid* with *mode*.

    The temporary is created exclusively and without following a link, so one
    planted under its name fails the write instead of redirecting it.
    """
    temporary = f".{name}.tmp"
    with contextlib.suppress(FileNotFoundError):
        os.unlink(temporary, dir_fd=directory)
    fd = os.open(
        temporary,
        os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
        mode,
        dir_fd=directory,
    )
    with os.fdopen(fd, "wb") as handle:
        if os.geteuid() == 0:
            os.fchown(fd, 0, gid)
        os.fchmod(fd, mode)
        handle.write(data)
    os.replace(temporary, name, src_dir_fd=directory, dst_dir_fd=directory)


def _unlink(directory: int, *names: str) -> None:
    for name in names:
        with contextlib.suppress(FileNotFoundError):
            os.unlink(name, dir_fd=directory)


def _write_config(run_dir: Path) -> None:
    """Read the inputs and write the Caddyfile and certificates to *run_dir*."""
    env = _read_small(ENV_FILE)
    ports = read_ports(env.decode("utf-8", "replace") if env else "")

    cloud_on = CLOUD_MARKER.exists()
    # The container in cloud mode never served the uploaded certificate; its
    # catch-all stayed on the device's own authority. Kept that way.
    custom = None if cloud_on else read_pem_pair(
        CUSTOM_DIR / "fullchain.pem", CUSTOM_DIR / "privkey.pem"
    )
    cloud = read_pem_pair(
        CLOUD_DIR / "fullchain.pem", CLOUD_DIR / "privkey.pem"
    ) if cloud_on else None
    if custom is not None and not _loads(custom):
        _LOGGER.warning("The uploaded certificate does not load; serving the device's own")
        custom = None
    if cloud is not None and not _loads(cloud):
        _LOGGER.warning("The wildcard certificate does not load; the cloud site is left out")
        cloud = None
    if cloud_on and cloud is None:
        _LOGGER.warning("No wildcard certificate yet; the cloud site is left out")

    gid = _caddy_gid()
    run = _open_dir(run_dir, 0o750)
    try:
        with contextlib.suppress(FileExistsError):
            os.mkdir("tls", 0o750, dir_fd=run)
        tls = os.open(
            "tls", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC,
            dir_fd=run,
        )
        try:
            if os.geteuid() == 0:
                os.fchown(tls, 0, gid)
            os.fchmod(tls, 0o750)
            # A copy no longer in use goes, rather than outliving the upload
            # in a directory that survives restarts.
            for label, pair in (("custom", custom), ("cloud", cloud)):
                if pair is None:
                    _unlink(tls, f"{label}.crt", f"{label}.key")
                    continue
                _write(tls, f"{label}.crt", pair[0], 0o640, gid)
                _write(tls, f"{label}.key", pair[1], 0o640, gid)
        finally:
            os.close(tls)
        text = render(
            ports, custom=custom is not None, cloud=cloud is not None,
            tls_dir=run_dir / "tls",
        )
        _write(run, "Caddyfile", text.encode(), 0o640, gid)
    finally:
        os.close(run)
    _LOGGER.info(
        "Caddyfile written: panel %d, http %d, https %d, %s certificate%s",
        ports.web, ports.http, ports.https,
        "uploaded" if custom else "own", ", cloud site" if cloud else "",
    )


def _remove_tree(parent: Path, name: str) -> None:
    """Remove *parent*/*name*, a link itself rather than what it points to."""
    try:
        directory = _open_nofollow(parent, os.O_RDONLY | os.O_DIRECTORY)
    except FileNotFoundError:
        return
    try:
        st = os.stat(name, dir_fd=directory, follow_symlinks=False)
        if stat.S_ISDIR(st.st_mode):
            # Descends by descriptor and refuses a link swapped in meanwhile.
            shutil.rmtree(name, dir_fd=directory)
        else:
            os.unlink(name, dir_fd=directory)
    except FileNotFoundError:
        pass
    finally:
        os.close(directory)


def _follow_hostname() -> None:
    """Discard the device's authority when the recorded hostname differs.

    Its root and leaves name the recorded host. With no record the authority
    is kept: there is nothing to compare it with, and discarding it costs
    every user who installed its root.
    """
    current = socket.gethostname()
    state = _open_dir(STATE_DIR)
    try:
        recorded = _read_small(STATE_DIR / "last_hostname", 256)
        previous = recorded.decode("utf-8", "replace").strip() if recorded else None
        if previous == current:
            return
        if previous is not None:
            _LOGGER.warning("Hostname changed to %s; the local authority is renewed", current)
            _remove_tree(CADDY_DATA, "pki")
            _remove_tree(CADDY_DATA / "certificates", "local")
            _unlink(state, "root.crt")
        _write(state, "last_hostname", f"{current}\n".encode(), 0o644)
    finally:
        os.close(state)


def _export_root() -> bool:
    """Publish the authority's root where the panel can offer it for download.

    It is a certificate and nothing else, public by nature. Before the very
    first start Caddy has not created it yet; ``--export-root`` after the start
    picks it up.

    Returns:
        Whether there was a root to export.
    """
    root = _read_small(CADDY_DATA / "pki" / "authorities" / "local" / "root.crt")
    if root is None or not _is_cert(root):
        return False
    state = _open_dir(STATE_DIR)
    try:
        _write(state, "root.crt", root, 0o644)
    finally:
        os.close(state)
    return True


def _export_root_when_created() -> int:
    """Export the root once Caddy has created it; never fails.

    Caddy is already serving when this runs, and a missing download is no
    reason to stop it. Neither the Caddyfile nor the hostname is touched.
    """
    deadline = time.monotonic() + EXPORT_WAIT
    try:
        while not _export_root():
            if time.monotonic() >= deadline:
                _LOGGER.info("No root certificate yet; the next start or reload exports it")
                return 0
            time.sleep(0.5)
    except OSError as err:
        _LOGGER.warning("The root certificate could not be exported: %s", err)
    return 0


def _check() -> int:
    """Render into a temporary directory and have Caddy validate the result.

    Validation provisions the PKI app, so Caddy gets a throwaway home rather
    than seeding an authority under /root. Hostname and export are left alone:
    they change state, and a check must not.
    """
    with tempfile.TemporaryDirectory(prefix="boneio-proxy-check.") as scratch:
        run_dir = Path(scratch) / "run"
        _write_config(run_dir)
        env = {
            "PATH": "/usr/sbin:/usr/bin:/sbin:/bin",
            "HOME": scratch,
            "XDG_DATA_HOME": f"{scratch}/data",
            "XDG_CONFIG_HOME": f"{scratch}/config",
        }
        result = subprocess.run(
            [CADDY, "validate", "--config", str(run_dir / "Caddyfile"),
             "--adapter", "caddyfile"],
            env=env, capture_output=True, text=True, timeout=120, check=False,
        )
    if result.returncode != 0:
        _LOGGER.error("caddy validate failed: %s", result.stderr.strip()[-2000:])
        return 1
    _LOGGER.info("The configuration is valid")
    return 0


def main(argv: list[str] | None = None) -> int:
    """Write the configuration, export the root, or check the configuration.

    Args:
        argv: Command-line arguments, without the program name.

    Returns:
        The exit code.
    """
    parser = argparse.ArgumentParser(prog="boneio-proxy-config")
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument("--check", action="store_true")
    mode.add_argument("--reload", action="store_true")
    mode.add_argument("--export-root", action="store_true")
    args = parser.parse_args(argv)
    if args.export_root:
        return _export_root_when_created()
    if not (args.check or args.reload):
        # Both are best-effort: a full or read-only /var must not cost the
        # device its proxy, only a renewed authority or a fresh download.
        try:
            _follow_hostname()
        except OSError as err:
            _LOGGER.warning("The hostname could not be followed: %s", err)
    try:
        if args.check:
            return _check()
        _write_config(RUN_DIR)
    except (OSError, subprocess.SubprocessError) as err:
        # Without a Caddyfile Caddy does not start, which is the right failure:
        # the alternative is a proxy configured by whatever was there before.
        _LOGGER.error("The proxy configuration could not be written: %s", err)
        return 1
    try:
        _export_root()
    except OSError as err:
        _LOGGER.warning("The root certificate could not be exported: %s", err)
    return 0


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
    sys.exit(main())
