#!/usr/bin/env python3
"""boneio-containers — privileged container operations, by name only.

The ``boneio`` account is in the ``docker`` group, which is root-equivalent: a
container can bind-mount the host root and write to it. Removing the account
from that group is the fix, but the application still needs to start Node-RED
and reload Caddy — so those operations move here, behind a closed list of
verbs.

Two things make this worth doing rather than granting ``sudo docker compose``:

  * **The vector is the compose file, not the argument list.** ``docker compose
    up`` reads ``docker-compose.yaml`` from the project directory, and that file
    lives under /home/boneio. A sudoers rule permitting only ``docker compose
    up`` still lets anyone who can write that file run a container as root with
    the host filesystem mounted. So this helper refuses to act at all unless the
    compose file is root-owned and not writable by anyone else.
  * **No argument reaches docker from the caller.** Each verb expands to a fixed
    command. The only caller-supplied values are a log line count and a domain
    name, both validated.

Usage:
    boneio-containers <verb> [argument]
    boneio-containers --list-verbs
    boneio-containers --selftest

Exit codes:
    0  success
    1  refused, or the underlying command failed
    2  the compose file is not root-owned, so nothing was run
"""

from __future__ import annotations

import argparse
import json
import logging
import logging.handlers
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

LOG_FILE = "/var/log/boneio-containers.log"

#: The compose project. Hard-coded on purpose: a caller-supplied project
#: directory would be a caller-supplied compose file, which is the vector.
PROJECT_DIR = Path("/home/boneio/docker/nodered")
COMPOSE_FILE = PROJECT_DIR / "docker-compose.yaml"

#: Root-owned templates, restored from the pristine copy rather than written by
#: the application. The application used to write the compose file itself,
#: which is precisely what must stop.
TRUSTED_DIR = Path("/usr/lib/boneio/trusted")
COMPOSE_TEMPLATE = TRUSTED_DIR / "docker-compose.yaml"
COMPOSE_CLOUD_TEMPLATE = TRUSTED_DIR / "docker-compose-cloud.yaml"

CADDY_SERVICE = "caddy"
NODERED_SERVICE = "node-red"

_MAX_LOG_LINES = 2000

#: A container image tag, per Docker's own rules and no looser. Validated
#: before it can reach the compose file, which is what `docker compose up`
#: executes — the application used to rewrite that file itself to change the
#: Node-RED version, which is the same escalation path by another route.
_TAG_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$")
#: The line this helper is willing to change, and nothing else in the file.
_NODERED_IMAGE_RE = re.compile(
    r"^(?P<prefix>\s*image:\s*nodered/node-red:)(?P<tag>\S+)(?P<suffix>\s*)$",
    re.MULTILINE,
)


def _configure_logging() -> logging.Logger:
    """Set up logging without making the log file a hard requirement.

    Returns:
        The helper's logger.
    """
    handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
    try:
        handlers.append(
            logging.handlers.RotatingFileHandler(
                LOG_FILE, maxBytes=524_288, backupCount=1, encoding="utf-8"
            )
        )
    except OSError as exc:
        print(f"boneio-containers: cannot open {LOG_FILE}: {exc}", file=sys.stderr)
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(levelname)s] %(message)s",
        handlers=handlers,
    )
    return logging.getLogger("boneio-containers")


_LOGGER = _configure_logging()


class Refused(Exception):
    """The request was rejected; nothing was run."""


#: Verb → the arguments that follow ``docker compose -f <file>``. Constants
#: only: nothing in this table can be influenced from outside, and building the
#: argv at call time keeps it honest about which compose file is in play rather
#: than freezing the path at import.
VERBS: dict[str, tuple[str, ...]] = {
    "status": ("ps", "--format", "json"),
    "up": ("up", "-d"),
    "down": ("down",),
    "pull": ("pull",),
    "start-nodered": ("up", "-d", NODERED_SERVICE),
    "stop-nodered": ("stop", NODERED_SERVICE),
    "restart-nodered": ("restart", NODERED_SERVICE),
    "pull-nodered": ("pull", NODERED_SERVICE),
    "start-caddy": ("up", "-d", CADDY_SERVICE),
    "restart-caddy": ("restart", CADDY_SERVICE),
    "reload-caddy": (
        # The container runs /tmp/Caddyfile, written by init-certs.sh on every
        # start. /etc/caddy/Caddyfile is the image's stock config: reloading it
        # takes HTTPS down until the container is restarted.
        "exec", CADDY_SERVICE, "caddy", "reload", "--config", "/tmp/Caddyfile"
    ),
}

#: Verbs that talk to the docker daemon rather than to a compose project.
DAEMON_VERBS: dict[str, tuple[str, ...]] = {
    "ps": ("ps", "-a", "--format", "json"),
    "names": ("ps", "-a", "--format", "{{.Names}}"),
}

#: How much of a container log the diagnostics bundle takes.
_CONTAINER_LOG_LINES = 200
#: A container name, per Docker's own rules. Checked, and then checked again
#: against the containers that actually exist — a name is the one piece of
#: caller data that reaches docker here, so a regex alone is not enough: it
#: would still admit anything shaped like a name, including a flag.
_CONTAINER_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")


def _compose(*args: str) -> list[str]:
    """A docker compose command for the fixed project.

    Args:
        *args: Verb and arguments to pass to compose.

    Returns:
        The full argv.
    """
    return ["docker", "compose", "-f", str(COMPOSE_FILE), *args]


def _argv_for(verb: str) -> list[str]:
    """The command a fixed verb expands to.

    Args:
        verb: One of :data:`VERBS` or :data:`DAEMON_VERBS`.

    Returns:
        The full argv.

    Raises:
        KeyError: If the verb is not a fixed one.
    """
    if verb in DAEMON_VERBS:
        return ["docker", *DAEMON_VERBS[verb]]
    return _compose(*VERBS[verb])

#: Verbs that take one validated argument.
PARAMETERISED_VERBS = {
    "logs-caddy",
    "logs-nodered",
    "apply-cloud-template",
    "remove-cloud-template",
    "set-nodered-image",
    "logs-container",
}

ALL_VERBS = sorted(set(VERBS) | set(DAEMON_VERBS) | PARAMETERISED_VERBS)

#: Verbs that only read. They are allowed to run even when the compose file is
#: not yet root-owned, because refusing them would leave the UI unable to say
#: what is wrong.
READ_ONLY_VERBS = {
    "status", "ps", "names", "logs-caddy", "logs-nodered", "logs-container",
}


def _assert_root() -> None:
    """Exit unless running as root."""
    if os.geteuid() != 0:
        _LOGGER.error("boneio-containers must be run as root via sudo.")
        sys.exit(1)


def _root_owned(path: Path) -> bool:
    """Whether *path* is a real file owned by root and not writable by others.

    Args:
        path: Path to inspect.

    Returns:
        True when root can trust the file's contents.
    """
    try:
        st = path.lstat()
    except OSError:
        return False
    if not os.path.isfile(path) or os.path.islink(path):
        return False
    return st.st_uid == 0 and not st.st_mode & 0o022


def _assert_compose_trustworthy() -> None:
    """Refuse to run compose against a file the caller could have written.

    Raises:
        Refused: If the compose file is missing or not root-owned.
    """
    if not _root_owned(COMPOSE_FILE):
        raise Refused(
            f"{COMPOSE_FILE} is missing, a symlink, or not owned by root. "
            "Running compose against it would let whoever can write that file "
            "start a container as root with the host filesystem mounted — the "
            "file is the vector, not the command line. Reinstall it from "
            f"{COMPOSE_TEMPLATE} with a migration."
        )


def _run(argv: list[str], timeout: int = 120) -> int:
    """Run a fixed command in the project directory.

    Args:
        argv: The command to run.
        timeout: Seconds to allow.

    Returns:
        Process exit status; output is passed through.
    """
    _LOGGER.info("RUN: %s", " ".join(argv))
    try:
        result = subprocess.run(
            argv, cwd=str(PROJECT_DIR), capture_output=True, text=True, timeout=timeout
        )
    except subprocess.TimeoutExpired:
        _LOGGER.error("timed out after %ds: %s", timeout, " ".join(argv))
        return 1
    except OSError as exc:
        _LOGGER.error("cannot run %s: %s", argv[0], exc)
        return 1

    if result.stdout:
        sys.stdout.write(result.stdout)
    if result.returncode != 0:
        _LOGGER.error("rc=%d: %s", result.returncode, result.stderr.strip())
        sys.stderr.write(result.stderr)
    return result.returncode


def _logs(service: str, argument: str | None) -> int:
    """Show the tail of a service's log.

    Args:
        service: Compose service name.
        argument: Number of lines, as text.

    Returns:
        Process exit status.

    Raises:
        Refused: If the line count is not a sane integer.
    """
    lines = 200
    if argument is not None:
        if not argument.isdigit() or not 1 <= int(argument) <= _MAX_LOG_LINES:
            raise Refused(
                f"log line count must be an integer between 1 and {_MAX_LOG_LINES}"
            )
        lines = int(argument)
    return _run(_compose("logs", "--tail", str(lines), "--no-color", service), timeout=60)


def _install_compose(source: Path) -> int:
    """Install a root-owned compose file, copied from a trusted template.

    Nothing is substituted into it. The cloud template serves a wildcard
    certificate and takes the hostname from the container itself, so no
    caller-supplied value belongs in this file — which is the point, since the
    file is what ``docker compose up`` executes.

    Args:
        source: Template in the pristine copy.

    Returns:
        0 on success.

    Raises:
        Refused: If the template is missing or not root-owned.
    """
    if not _root_owned(source):
        raise Refused(
            f"template {source} is missing or not root-owned. The compose file "
            "must come from the pristine copy, not from the application."
        )
    content = source.read_text(encoding="utf-8")

    PROJECT_DIR.mkdir(parents=True, exist_ok=True)
    _back_up_if_customised(content)
    with tempfile.NamedTemporaryFile(
        dir=PROJECT_DIR, delete=False, suffix=".tmp", mode="w", encoding="utf-8"
    ) as tmp:
        tmp.write(content)
        tmp_path = tmp.name
    try:
        os.chmod(tmp_path, 0o644)
        if os.geteuid() == 0:
            # The whole point of installing it from here is that it ends up
            # root-owned; running as root this cannot fail, and not running as
            # root there is no privilege to protect.
            os.chown(tmp_path, 0, 0)
        os.replace(tmp_path, COMPOSE_FILE)
    except OSError:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise
    _LOGGER.info("INSTALLED: %s from %s (root:root 0644)", COMPOSE_FILE, source.name)
    return 0


def _back_up_if_customised(replacement: str) -> None:
    """Keep a copy of a compose file that matches neither template.

    The code this replaces wrote a ``.yaml.bak`` before switching, which mattered
    for anyone who had hand-edited the file. Hand-editing is what is being taken
    away — the file is what ``docker compose up`` executes — but taking it away
    should not silently discard what somebody already wrote.

    Args:
        replacement: The content about to be installed.
    """
    if not COMPOSE_FILE.is_file() or COMPOSE_FILE.is_symlink():
        return
    try:
        current = COMPOSE_FILE.read_text(encoding="utf-8")
    except OSError as exc:
        _LOGGER.warning("Cannot read %s to back it up: %s", COMPOSE_FILE, exc)
        return
    if current == replacement:
        return

    known = []
    for template in (COMPOSE_TEMPLATE, COMPOSE_CLOUD_TEMPLATE):
        try:
            known.append(template.read_text(encoding="utf-8"))
        except OSError:
            continue
    if current in known:
        # One of ours; the template it came from is the backup.
        return

    backup = COMPOSE_FILE.with_suffix(".yaml.bak")
    if backup.exists():
        _LOGGER.info("Keeping the existing backup at %s", backup)
        return
    try:
        backup.write_text(current, encoding="utf-8")
        os.chmod(backup, 0o644)
        _LOGGER.info(
            "Backed up a customised %s to %s before replacing it",
            COMPOSE_FILE, backup,
        )
    except OSError as exc:
        _LOGGER.warning("Could not back up %s: %s", COMPOSE_FILE, exc)


def _logs_container(argument: str | None) -> int:
    """Show the tail of one container's log, by name.

    The diagnostics bundle reads every container's log, and compose prefixes
    the names with the project (``nodered-caddy-1``, not ``caddy``), so a fixed
    list would produce "No such container" for exactly the logs somebody wanted.
    The name therefore has to come from the caller — and is accepted only if it
    matches a container that exists, which is a stronger check than any pattern.

    Args:
        argument: The container name.

    Returns:
        Process exit status.

    Raises:
        Refused: If the name is malformed or is not a container on this host.
    """
    if not argument or not _CONTAINER_NAME_RE.match(argument):
        raise Refused(f"not a valid container name: {argument!r}")

    listing = subprocess.run(
        ["docker", *DAEMON_VERBS["names"]],
        cwd=str(PROJECT_DIR), capture_output=True, text=True, timeout=30,
    )
    if listing.returncode != 0:
        raise Refused(
            f"cannot list containers, so {argument!r} cannot be checked: "
            f"{listing.stderr.strip()}"
        )
    existing = {line.strip() for line in listing.stdout.splitlines() if line.strip()}
    if argument not in existing:
        raise Refused(f"no such container: {argument!r}")

    return _run(
        ["docker", "logs", "--tail", str(_CONTAINER_LOG_LINES), argument], timeout=60
    )


def _set_nodered_image(argument: str | None) -> int:
    """Change the Node-RED image tag in the compose file, and nothing else.

    The application used to rewrite the whole compose file to do this. That file
    is what ``docker compose up`` executes, so being able to write it is being
    able to run a container as root with the host filesystem mounted — the same
    hole as the docker group, reached through the update flow. Here the only
    thing that can change is one tag, matched by a fixed pattern.

    Args:
        argument: The image tag.

    Returns:
        0 on success.

    Raises:
        Refused: If the tag is implausible or the image line is not there.
    """
    if not argument or not _TAG_RE.match(argument):
        raise Refused(
            f"not a valid image tag: {argument!r}. Checked before it reaches the "
            "compose file, so a tag cannot smuggle YAML into it."
        )

    content = COMPOSE_FILE.read_text(encoding="utf-8")
    match = _NODERED_IMAGE_RE.search(content)
    if match is None:
        raise Refused(
            f"no 'image: nodered/node-red:<tag>' line in {COMPOSE_FILE}; refusing "
            "to guess what to edit"
        )
    if match.group("tag") == argument:
        _LOGGER.info("Node-RED image is already nodered/node-red:%s", argument)
        return 0

    updated = _NODERED_IMAGE_RE.sub(
        lambda m: f"{m.group('prefix')}{argument}{m.group('suffix')}", content, count=1
    )
    _back_up_if_customised(updated)

    with tempfile.NamedTemporaryFile(
        dir=PROJECT_DIR, delete=False, suffix=".tmp", mode="w", encoding="utf-8"
    ) as tmp:
        tmp.write(updated)
        tmp_path = tmp.name
    try:
        os.chmod(tmp_path, 0o644)
        if os.geteuid() == 0:
            os.chown(tmp_path, 0, 0)
        os.replace(tmp_path, COMPOSE_FILE)
    except OSError:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise
    _LOGGER.info(
        "Node-RED image set to nodered/node-red:%s (was %s)",
        argument, match.group("tag"),
    )
    return 0


def _apply_cloud_template(argument: str | None) -> int:
    """Switch the compose project to the cloud template.

    Args:
        argument: Must be absent. The template needs no parameter, so accepting
            one would only create a path for caller data to reach the file.

    Returns:
        Process exit status.

    Raises:
        Refused: If an argument is supplied.
    """
    if argument is not None:
        raise Refused("apply-cloud-template takes no argument")
    _LOGGER.info("applying the cloud compose template")
    return _install_compose(COMPOSE_CLOUD_TEMPLATE)


def _remove_cloud_template(argument: str | None) -> int:
    """Restore the plain compose template.

    Args:
        argument: Unused.

    Returns:
        Process exit status.
    """
    if argument is not None:
        raise Refused("remove-cloud-template takes no argument")
    _LOGGER.info("restoring the plain compose template")
    return _install_compose(COMPOSE_TEMPLATE)


def selftest() -> int:
    """Check the helper can do its job, changing nothing.

    Returns:
        0 when healthy.
    """
    problems: list[str] = []
    if shutil.which("docker") is None:
        problems.append("docker is not installed")
    if not PROJECT_DIR.is_dir():
        problems.append(f"the compose project directory {PROJECT_DIR} does not exist")
    if not _root_owned(COMPOSE_FILE):
        problems.append(
            f"{COMPOSE_FILE} is not root-owned, so every write verb will refuse"
        )
    for template in (COMPOSE_TEMPLATE, COMPOSE_CLOUD_TEMPLATE):
        if not _root_owned(template):
            problems.append(f"template {template} is missing or not root-owned")

    for problem in problems:
        _LOGGER.error("selftest: %s", problem)
    if problems:
        return 1
    _LOGGER.info("selftest: %d verbs available, compose file trusted.", len(ALL_VERBS))
    return 0


def main(argv: list[str] | None = None) -> int:
    """Entry point.

    Returns:
        Process exit status.
    """
    parser = argparse.ArgumentParser(
        description="boneIO privileged container operations"
    )
    parser.add_argument("verb", nargs="?", help=f"one of: {', '.join(ALL_VERBS)}")
    # A value starting with "-" would otherwise be parsed as an option and make
    # argparse exit before the domain validator ever sees it. The validator
    # should be the thing that rejects it, with a message that says why.
    parser.add_argument(
        "argument", nargs="?", default=None,
        help="log line count, or a domain",
    )
    parser.add_argument(
        "--list-verbs", action="store_true", help="print the allowed verbs as JSON"
    )
    parser.add_argument(
        "--selftest", action="store_true", help="check the helper, change nothing"
    )
    args, unparsed = parser.parse_known_args(argv)
    if unparsed:
        # Anything argparse could not place is still caller input, so it is
        # refused rather than ignored.
        if args.argument is None and len(unparsed) == 1:
            args.argument = unparsed[0]
        else:
            _LOGGER.error("REFUSED: unexpected arguments: %s", unparsed)
            return 1

    if args.list_verbs:
        print(json.dumps(ALL_VERBS))
        return 0

    _assert_root()

    if args.selftest:
        return selftest()

    if not args.verb:
        parser.error("a verb is required")

    try:
        if args.verb not in ALL_VERBS:
            raise Refused(
                f"unknown verb {args.verb!r}. Allowed: {', '.join(ALL_VERBS)}"
            )

        if args.verb not in READ_ONLY_VERBS:
            _assert_compose_trustworthy()

        if args.verb == "logs-caddy":
            return _logs(CADDY_SERVICE, args.argument)
        if args.verb == "logs-nodered":
            return _logs(NODERED_SERVICE, args.argument)
        if args.verb == "logs-container":
            return _logs_container(args.argument)
        if args.verb == "set-nodered-image":
            return _set_nodered_image(args.argument)
        if args.verb == "apply-cloud-template":
            return _apply_cloud_template(args.argument)
        if args.verb == "remove-cloud-template":
            return _remove_cloud_template(args.argument)

        if args.argument is not None:
            raise Refused(f"verb {args.verb!r} takes no argument")
        return _run(_argv_for(args.verb))
    except Refused as exc:
        _LOGGER.error("REFUSED: %s", exc)
        return 2 if "not owned by root" in str(exc) else 1


if __name__ == "__main__":
    sys.exit(main())
