Files
fiscality-maschera/manager-solution/configurator_server.py
2026-07-06 12:18:41 +02:00

272 lines
9.3 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"
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 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"))
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
self._send_json({"ok": True, "config": compact, "normalized": normalized})
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()