#!/usr/bin/env python3 import json import os import shlex import socket import subprocess import sys from http import HTTPStatus from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path ROOT_DIR = Path(__file__).resolve().parent PROJECT_DIR = ROOT_DIR.parent DASHBOARD_DIR = ROOT_DIR / "dashboard" CONFIG_PATH = ROOT_DIR / "manager" / "config.json" MANAGER_DIR = ROOT_DIR / "manager" RECOVERY_COMMANDS_PATH = PROJECT_DIR / "docker-run-commands.sh" MANAGER_CONTAINER_NAME = os.getenv("FIS_MANAGER_CONTAINER_NAME", "fis_manager") MANAGER_RESTART_TIMEOUT_SECONDS = int(os.getenv("MANAGER_RESTART_TIMEOUT_SECONDS", "30")) sys.path.insert(0, str(MANAGER_DIR)) from config_model import compact_config, normalize_config # noqa: E402 def shell_quote(value): return shlex.quote(str(value)) def append_option(lines, option, value=None): if value is None or value == "": return if value is True: lines.append(f" {option} \\") else: lines.append(f" {option} {shell_quote(value)} \\") def build_docker_run_command(user, image_name, publish_port=False): lines = [ "docker run -d \\", f" --name {shell_quote(user['container_name'])} \\", " --restart always \\", ] if publish_port: lines.append(f" -p {int(user['port'])}:3000 \\") for volume_name, mount_path in (user.get("volumes") or {}).items(): lines.append(f" --volume {shell_quote(f'{volume_name}:{mount_path}:rw')} \\") for key, value in (user.get("environment") or {}).items(): lines.append(f" --env {shell_quote(f'{key}={value}')} \\") append_option(lines, "--memory", user.get("mem_limit")) append_option(lines, "--memory-swap", user.get("memswap_limit")) if user.get("privileged"): lines.append(" --privileged \\") for target, options in (user.get("tmpfs") or {}).items(): tmpfs_value = target if not options else f"{target}:{options}" lines.append(f" --tmpfs {shell_quote(tmpfs_value)} \\") for dns_server in user.get("dns") or []: lines.append(f" --dns {shell_quote(dns_server)} \\") for host, address in (user.get("extra_hosts") or {}).items(): lines.append(f" --add-host {shell_quote(f'{host}:{address}')} \\") lines.extend([ " --tty \\", " --interactive \\", f" {shell_quote(image_name)}", ]) return "\n".join(lines) def write_recovery_commands(config): image_name = os.getenv("FIS_APP_IMAGE") or os.getenv("APP_IMAGE_NAME") or "fis" normalized = normalize_config(config) lines = [ "#!/bin/bash", "set -euo pipefail", "", "# Generated by ./configure.sh from manager-solution/manager/config.json.", "# These commands recreate/start the Fiscality user containers directly with Docker.", "# The manager normally proxies user ports; the first command block matches the manager", "# container creation and does not publish ports.", "", ] for user in normalized["users"]: lines.extend([ f"# User: {user['id']} - manager-equivalent container", f"docker volume create {shell_quote(next(iter(user['volumes'].keys())))} >/dev/null", ]) for volume_name in list(user["volumes"].keys())[1:]: lines.append(f"docker volume create {shell_quote(volume_name)} >/dev/null") lines.append(build_docker_run_command(user, image_name, publish_port=False)) lines.extend([ "", f"# Emergency direct access for {user['id']} on host port {user['port']}:", f"# docker rm -f {shell_quote(user['container_name'])} || true", ]) for command_line in build_docker_run_command(user, image_name, publish_port=True).splitlines(): lines.append(f"# {command_line}") lines.append("") RECOVERY_COMMANDS_PATH.write_text("\n".join(lines), encoding="utf-8") RECOVERY_COMMANDS_PATH.chmod(0o755) def write_recovery_commands_from_disk(): if not CONFIG_PATH.exists(): return with CONFIG_PATH.open(encoding="utf-8") as handle: write_recovery_commands(json.load(handle)) def restart_manager_container(): try: result = subprocess.run( ["docker", "restart", MANAGER_CONTAINER_NAME], check=True, capture_output=True, text=True, timeout=MANAGER_RESTART_TIMEOUT_SECONDS, ) except FileNotFoundError: return { "attempted": True, "ok": False, "error": "Docker CLI not found; run manually: docker restart " f"{MANAGER_CONTAINER_NAME}", } except subprocess.TimeoutExpired: return { "attempted": True, "ok": False, "error": f"Timed out restarting {MANAGER_CONTAINER_NAME}", } except subprocess.CalledProcessError as exc: error = (exc.stderr or exc.stdout or str(exc)).strip() if any(marker in error.lower() for marker in ("no such container", "no such object", "not found")): return { "attempted": False, "ok": True, "reason": "manager_not_running", "error": error, } return {"attempted": True, "ok": False, "error": error} return {"attempted": True, "ok": True, "output": result.stdout.strip()} def read_compact_config_from_disk(): if not CONFIG_PATH.exists(): return None try: with CONFIG_PATH.open(encoding="utf-8") as handle: return compact_config(json.load(handle)) except (OSError, ValueError, json.JSONDecodeError): return None def detect_lan_ip(): configured_ip = os.getenv("CONFIGURATOR_PUBLIC_IP") if configured_ip: return configured_ip try: output = subprocess.check_output( ["ip", "route", "get", "1.1.1.1"], stderr=subprocess.DEVNULL, text=True, ).strip() parts = output.split() if "src" in parts: candidate = parts[parts.index("src") + 1] if is_public_configurator_ip(candidate): return candidate except (OSError, subprocess.SubprocessError, IndexError): pass try: output = subprocess.check_output( ["ip", "-o", "-4", "addr", "show", "scope", "global"], stderr=subprocess.DEVNULL, text=True, ) for line in output.splitlines(): parts = line.split() interface = parts[1] candidate = parts[3].split("/", 1)[0] if is_ignored_interface(interface): continue if is_public_configurator_ip(candidate): return candidate except (OSError, subprocess.SubprocessError, IndexError): pass try: output = subprocess.check_output(["hostname", "-I"], stderr=subprocess.DEVNULL, text=True).strip() for candidate in output.split(): if is_public_configurator_ip(candidate): return candidate except (OSError, subprocess.SubprocessError): pass try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.connect(("8.8.8.8", 80)) candidate = sock.getsockname()[0] if is_public_configurator_ip(candidate): return candidate except OSError: pass candidate = socket.gethostbyname(socket.gethostname()) if is_public_configurator_ip(candidate): return candidate return "127.0.0.1" def is_ignored_interface(interface): ignored_prefixes = ("docker", "br-", "veth", "virbr", "podman", "lo") return interface.startswith(ignored_prefixes) def is_public_configurator_ip(candidate): if not candidate or "." not in candidate: return False if candidate.startswith("127.") or candidate.startswith("169.254."): return False if candidate.startswith("172.17."): return False return True class ConfiguratorHandler(SimpleHTTPRequestHandler): def __init__(self, *args, **kwargs): super().__init__(*args, directory=str(DASHBOARD_DIR), **kwargs) def do_GET(self): if self.path == "/api/config": try: self._send_json(self._read_config()) except ValueError as exc: self._send_json({"ok": False, "error": str(exc)}, status=HTTPStatus.BAD_REQUEST) return return super().do_GET() def do_POST(self): if self.path != "/api/config": self.send_error(HTTPStatus.NOT_FOUND, "Unknown endpoint") return try: content_length = int(self.headers.get("Content-Length", "0")) payload = self.rfile.read(content_length) raw = json.loads(payload.decode("utf-8")) config_existed = CONFIG_PATH.exists() previous_compact = read_compact_config_from_disk() normalized = normalize_config(raw) compact = compact_config(raw) CONFIG_PATH.write_text(json.dumps(compact, indent=2) + "\n", encoding="utf-8") write_recovery_commands(compact) except ValueError as exc: self._send_json({"ok": False, "error": str(exc)}, status=HTTPStatus.BAD_REQUEST) return except json.JSONDecodeError as exc: self._send_json({"ok": False, "error": f"Invalid JSON: {exc}"}, status=HTTPStatus.BAD_REQUEST) return manager_restart = {"attempted": False, "ok": True, "reason": "unchanged"} if compact != previous_compact: if config_existed: manager_restart = restart_manager_container() else: manager_restart = {"attempted": False, "ok": True, "reason": "first_install"} self._send_json( { "ok": True, "config": compact, "normalized": normalized, "manager_restart": manager_restart, } ) def log_message(self, format, *args): return def _read_config(self): if not CONFIG_PATH.exists(): return { "defaults": { "container_prefix": "fis-", "volumes": {"v-conf-{id}": "/config"}, "environment": {"PUID": "1000", "PGID": "1000"}, "mem_limit": "3g", "memswap_limit": "5g", "privileged": True, }, "users": [], } with CONFIG_PATH.open(encoding="utf-8") as handle: return compact_config(json.load(handle)) def _send_json(self, payload, status=HTTPStatus.OK): body = json.dumps(payload, indent=2).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def main(): bind_host = os.getenv("CONFIGURATOR_BIND_HOST", "0.0.0.0") public_host = detect_lan_ip() port = int(os.getenv("CONFIGURATOR_PORT", "8765")) try: write_recovery_commands_from_disk() except ValueError as exc: print(f"Could not generate {RECOVERY_COMMANDS_PATH.name}: {exc}", flush=True) server = ThreadingHTTPServer((bind_host, port), ConfiguratorHandler) print(f"Configurator available at http://{public_host}:{port}", flush=True) print(f"Docker recovery commands: {RECOVERY_COMMANDS_PATH}", flush=True) server.serve_forever() if __name__ == "__main__": main()