#!/usr/bin/env python3 # Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) # SPDX-License-Identifier: MIT # # install.py — install nuntius as a systemd service. # # nuntius ships as a single static binary. Build it on your workstation, upload # the binary, the systemd unit, and the .env template to the server, then run # this script there as root from that directory: # # scp bin/nuntius nuntius.service .env.example user@host:/tmp/nuntius/ # ssh user@host # cd /tmp/nuntius && sudo python3 install.py # # It expects ./nuntius and ./nuntius.service (and optionally ./.env.example) in # the current working directory. Idempotent: re-running is safe — it skips the # user, config, and .env when they already exist, and never starts the service. from __future__ import annotations import argparse import logging import os import shutil import subprocess import sys from typing import NoReturn NUNTIUS_USER = "nuntius" BIN_DEST = "/usr/local/bin/nuntius" CONFIG_DIR = "/etc/nuntius" CONFIG_FILE = "/etc/nuntius/config.toml" ENV_FILE = "/etc/nuntius/.env" DATA_DIR = "/var/lib/nuntius" SERVICE_DEST = "/etc/systemd/system/nuntius.service" # --------------------------------------------------------------------------- # Coloured logging # --------------------------------------------------------------------------- class _ColourFormatter(logging.Formatter): """Prefix each log line with a coloured level marker, mimicking the bash script.""" _COLOURS: dict[int, str] = { logging.INFO: "\033[1;34m", # blue logging.WARNING: "\033[1;33m", # yellow logging.ERROR: "\033[1;31m", # red } _LABELS: dict[int, str] = { logging.INFO: "==>", logging.WARNING: "WARN:", logging.ERROR: "ERROR:", } _RESET = "\033[0m" def format(self, record: logging.LogRecord) -> str: colour = self._COLOURS.get(record.levelno, "") label = self._LABELS.get(record.levelno, "") if colour and label: return f"{colour}{label}{self._RESET} {record.getMessage()}" return record.getMessage() def _setup_logging() -> None: """Configure the root logger with coloured console output.""" logger = logging.getLogger() logger.setLevel(logging.INFO) handler = logging.StreamHandler() handler.setFormatter(_ColourFormatter()) # Remove any handlers added by basicConfig or other imports. logger.handlers.clear() logger.addHandler(handler) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]: """Run a command via subprocess and check for failure (unless told otherwise).""" return subprocess.run(cmd, check=True, text=True, **kwargs) # --------------------------------------------------------------------------- # Install steps # --------------------------------------------------------------------------- def require_root() -> None: """Exit if not running as root.""" if os.geteuid() != 0: logging.error("Run as root: sudo python3 install.py") sys.exit(1) def require_files() -> None: """Verify that the binary and the systemd unit are present in CWD.""" if not os.path.isfile("./nuntius"): logging.error( "./nuntius binary not found — copy it here first (e.g. rsync bin/nuntius)." ) sys.exit(1) if not os.path.isfile("./nuntius.service"): logging.error( "./nuntius.service not found — paste the unit from docs/deployment.md (Option A)." ) sys.exit(1) def create_user() -> None: """Create the system user if it does not already exist.""" try: _run(["id", NUNTIUS_USER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) logging.info("User %s already exists.", NUNTIUS_USER) except subprocess.CalledProcessError: logging.info("Creating system user %s…", NUNTIUS_USER) _run( [ "useradd", "--system", "--shell", "/usr/sbin/nologin", "--home-dir", DATA_DIR, "--user-group", NUNTIUS_USER, ] ) def create_data_dir() -> None: """Ensure the data directory exists with correct ownership and permissions.""" logging.info("Setting up %s…", DATA_DIR) os.makedirs(DATA_DIR, exist_ok=True) shutil.chown(DATA_DIR, user=NUNTIUS_USER, group=NUNTIUS_USER) os.chmod(DATA_DIR, 0o750) def install_binary() -> None: """Copy the nuntius binary to /usr/local/bin with mode 0755.""" logging.info("Installing binary to %s…", BIN_DEST) shutil.copy2("./nuntius", BIN_DEST) os.chmod(BIN_DEST, 0o755) def install_service() -> None: """Copy the systemd unit file with mode 0644.""" logging.info("Installing systemd unit %s…", SERVICE_DEST) shutil.copy2("./nuntius.service", SERVICE_DEST) os.chmod(SERVICE_DEST, 0o644) def generate_config() -> None: """Auto-generate the TOML config by running nuntius briefly if config is absent.""" if os.path.isfile(CONFIG_FILE): logging.info("Config %s already exists; leaving it untouched.", CONFIG_FILE) return logging.info("Generating %s…", CONFIG_FILE) os.makedirs(CONFIG_DIR, exist_ok=True) # nuntius writes the template config on first start; run it briefly, then # let `timeout` send SIGTERM (graceful shutdown). The write happens at # startup, before the timeout elapses. try: _run(["timeout", "-k", "5s", "4s", BIN_DEST], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except subprocess.CalledProcessError: # timeout exits 124 on SIGTERM, 137 on SIGKILL — both are expected. pass if not os.path.isfile(CONFIG_FILE): logging.warning("nuntius did not create %s; check the uploaded binary.", CONFIG_FILE) # Fix ownership in case nuntius wrote files as root. _run(["chown", "-R", f"{NUNTIUS_USER}:{NUNTIUS_USER}", DATA_DIR]) def install_env() -> None: """Copy .env.example to /etc/nuntius/.env if it does not already exist.""" if os.path.isfile(ENV_FILE): logging.info("%s already exists; leaving it untouched.", ENV_FILE) elif os.path.isfile("./.env.example"): logging.info("Creating %s from .env.example (edit it!)…", ENV_FILE) shutil.copy2("./.env.example", ENV_FILE) os.chmod(ENV_FILE, 0o600) _run(["chown", f"root:{NUNTIUS_USER}", ENV_FILE]) else: logging.warning( ".env.example not found; skipping %s. Create it before starting.", ENV_FILE ) def enable_service() -> None: """Reload systemd and enable the nuntius service (does not start it).""" logging.info("Reloading systemd and enabling nuntius…") _run(["systemctl", "daemon-reload"]) _run(["systemctl", "enable", "nuntius.service"]) def final_notes() -> None: """Print post-install instructions.""" print( f""" ============================================================ ✓ nuntius installed and enabled, but NOT started. Next manual steps: 1. Set the SMTP password: sudo $EDITOR {ENV_FILE} 2. Edit the auto-generated config (smtp.host, smtp.user, allowed_origins): sudo $EDITOR {CONFIG_FILE} 3. Start the service: sudo systemctl start nuntius sudo journalctl -u nuntius -f 4. Add the nginx location block (see README.md), then: sudo nginx -t && sudo systemctl reload nginx ============================================================ """ ) # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="install.py", description="install nuntius as a systemd service.", epilog=( "Idempotent: re-running is safe. The service is enabled but never started." ), ) return parser # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(argv: list[str] | None = None) -> NoReturn: """Parse args and run all install steps in order.""" _setup_logging() parser = _build_parser() parser.parse_args(argv) require_root() require_files() create_user() create_data_dir() install_binary() install_service() generate_config() install_env() enable_service() final_notes() sys.exit(0) if __name__ == "__main__": main()