Files
fiscality-maschera/manager-solution/configurator_server.py
2026-07-06 17:06:03 +02:00

336 lines
12 KiB
Python

#!/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()