diff --git a/Dockerfile b/Dockerfile
index a583731..8d97cc3 100755
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,6 +11,11 @@ RUN mkdir -p /app /defaults \
RUN ln -snf /usr/share/zoneinfo/Europe/Rome /etc/localtime && \
echo "Europe/Rome" > /etc/timezone
+# The base image may include Docker's apt repository, but this image does not
+# install Docker packages. Disable it so apt updates do not fail on stale keys.
+RUN rm -f /etc/apt/sources.list.d/docker.list \
+ /etc/apt/sources.list.d/download_docker_com_linux_debian.list
+
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
firefox-esr gnumeric xdg-utils \
@@ -255,4 +260,4 @@ RUN chmod +x /entrypoint.sh
# Set entrypoint and default cmd
ENTRYPOINT ["/entrypoint.sh"]
-CMD ["/init"]
\ No newline at end of file
+CMD ["/init"]
diff --git a/configure.sh b/configure.sh
new file mode 100755
index 0000000..3668ae2
--- /dev/null
+++ b/configure.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+cd "${ROOT_DIR}/manager-solution"
+exec python3 configurator_server.py
diff --git a/docker-run-commands.sh b/docker-run-commands.sh
new file mode 100755
index 0000000..ec3a634
--- /dev/null
+++ b/docker-run-commands.sh
@@ -0,0 +1,42 @@
+#!/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.
+
+# User: mario - manager-equivalent container
+docker volume create v-conf-mario >/dev/null
+docker run -d \
+ --name fis-mario \
+ --restart always \
+ --volume v-conf-mario:/config:rw \
+ --env PUID=1000 \
+ --env PGID=1000 \
+ --env PASSWORD=mario \
+ --env CUSTOM_USER=mario \
+ --memory 3g \
+ --memory-swap 5g \
+ --privileged \
+ --tty \
+ --interactive \
+ fis
+
+# Emergency direct access for mario on host port 3001:
+# docker rm -f fis-mario || true
+# docker run -d \
+# --name fis-mario \
+# --restart always \
+# -p 3001:3000 \
+# --volume v-conf-mario:/config:rw \
+# --env PUID=1000 \
+# --env PGID=1000 \
+# --env PASSWORD=mario \
+# --env CUSTOM_USER=mario \
+# --memory 3g \
+# --memory-swap 5g \
+# --privileged \
+# --tty \
+# --interactive \
+# fis
diff --git a/fiscality-installer.zip b/fiscality-installer.zip
new file mode 100644
index 0000000..6239f58
Binary files /dev/null and b/fiscality-installer.zip differ
diff --git a/install.sh b/install.sh
index f213f15..7fd5572 100755
--- a/install.sh
+++ b/install.sh
@@ -21,6 +21,9 @@ if [[ ! -f "${MANAGER_DIR}/manager/config.json" ]]; then
exit 1
fi
+echo "Validating manager config"
+python3 "${MANAGER_DIR}/manager/validate_config.py" "${MANAGER_DIR}/manager/config.json" >/dev/null
+
ensure_image_exists() {
local image_name="$1"
if ! docker image inspect "${image_name}" >/dev/null 2>&1; then
diff --git a/manager-solution/__pycache__/configurator_server.cpython-313.pyc b/manager-solution/__pycache__/configurator_server.cpython-313.pyc
new file mode 100644
index 0000000..614f0c2
Binary files /dev/null and b/manager-solution/__pycache__/configurator_server.cpython-313.pyc differ
diff --git a/manager-solution/configurator_server.py b/manager-solution/configurator_server.py
new file mode 100644
index 0000000..1692086
--- /dev/null
+++ b/manager-solution/configurator_server.py
@@ -0,0 +1,271 @@
+#!/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()
diff --git a/manager-solution/dashboard/index.html b/manager-solution/dashboard/index.html
new file mode 100644
index 0000000..5b8045d
--- /dev/null
+++ b/manager-solution/dashboard/index.html
@@ -0,0 +1,493 @@
+
+
+
+
+
+ Installazione Fiscality
+
+
+
+
+
+
+
Configuratore installazione
+
Configura Fiscality senza modificare il JSON a mano.
+
Crea gli utenti aziendali, controlla porte e nomi dei container, salva in manager/config.json, poi esegui ./install.sh.
+
+
+
+
+
+
+
Caricamento configurazione...
+
+
+
Procedura consigliata
+
+
1. Apri questa pagina con ./configure.sh.
+
2. Aggiungi una voce per ogni dipendente o postazione che deve usare Fiscality.
+
3. Salva la configurazione, poi installa con ./install.sh.
+
4. Comunica agli utenti la porta dedicata o l'URL gateway condiviso, se lo abiliti.
+
+
+
+
+
+ Valori predefiniti
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Utente
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/manager-solution/docker-compose.yml b/manager-solution/docker-compose.yml
index e8cd86c..db55534 100644
--- a/manager-solution/docker-compose.yml
+++ b/manager-solution/docker-compose.yml
@@ -16,4 +16,5 @@ services:
- INACTIVITY_SECONDS=${INACTIVITY_SECONDS:-10}
- IDLE_CHECK_INTERVAL=${IDLE_CHECK_INTERVAL:-10}
- STARTUP_TIMEOUT_SECONDS=${STARTUP_TIMEOUT_SECONDS:-60}
- - STARTUP_STABILIZATION_SECONDS=${STARTUP_STABILIZATION_SECONDS:-5}
+ - HTTP_READY_PATH=${HTTP_READY_PATH:-/}
+ - HTTP_READY_TIMEOUT_SECONDS=${HTTP_READY_TIMEOUT_SECONDS:-1.5}
diff --git a/manager-solution/manager/Dockerfile b/manager-solution/manager/Dockerfile
index 3cc182b..982e641 100644
--- a/manager-solution/manager/Dockerfile
+++ b/manager-solution/manager/Dockerfile
@@ -6,9 +6,11 @@ COPY requirements.txt .
RUN pip install -r requirements.txt
+COPY config_model.py .
COPY manager.py .
COPY config.json .
+COPY validate_config.py .
ENV INACTIVITY_SECONDS=10
-CMD ["python", "manager.py"]
\ No newline at end of file
+CMD ["python", "manager.py"]
diff --git a/manager-solution/manager/__pycache__/config_model.cpython-313.pyc b/manager-solution/manager/__pycache__/config_model.cpython-313.pyc
new file mode 100644
index 0000000..4231a7a
Binary files /dev/null and b/manager-solution/manager/__pycache__/config_model.cpython-313.pyc differ
diff --git a/manager-solution/manager/__pycache__/manager.cpython-313.pyc b/manager-solution/manager/__pycache__/manager.cpython-313.pyc
index 6585f27..d23b70d 100644
Binary files a/manager-solution/manager/__pycache__/manager.cpython-313.pyc and b/manager-solution/manager/__pycache__/manager.cpython-313.pyc differ
diff --git a/manager-solution/manager/__pycache__/test_manager.cpython-313.pyc b/manager-solution/manager/__pycache__/test_manager.cpython-313.pyc
index 223547c..0f0622b 100644
Binary files a/manager-solution/manager/__pycache__/test_manager.cpython-313.pyc and b/manager-solution/manager/__pycache__/test_manager.cpython-313.pyc differ
diff --git a/manager-solution/manager/__pycache__/validate_config.cpython-313.pyc b/manager-solution/manager/__pycache__/validate_config.cpython-313.pyc
new file mode 100644
index 0000000..bd09e44
Binary files /dev/null and b/manager-solution/manager/__pycache__/validate_config.cpython-313.pyc differ
diff --git a/manager-solution/manager/config.json b/manager-solution/manager/config.json
deleted file mode 100644
index 8bf8ded..0000000
--- a/manager-solution/manager/config.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
- "defaults": {
- "container_prefix": "fis-",
- "volumes": {
- "v-conf-{id}": "/config"
- },
- "environment": {
- "PUID": "1000",
- "PGID": "1000"
- },
- "mem_limit": "3g",
- "memswap_limit": "5g",
- "privileged": true
- },
- "users": [
- {
- "id": "user1",
- "port": 3001,
- "environment": {
- "PASSWORD": "user1"
- },
- "volumes": {
- "v-conf-user1": "/config"
- }
- },
- {
- "id": "user2",
- "port": 3002,
- "environment": {
- "PASSWORD": "user2"
- },
- "volumes": {
- "v-conf-user2": "/config"
- }
- },
- {
- "id": "user3",
- "port": 3003,
- "environment": {
- "PASSWORD": "user3"
- },
- "volumes": {
- "v-conf-user3": "/config"
- }
- }
- ]
-}
diff --git a/manager-solution/manager/config_model.py b/manager-solution/manager/config_model.py
new file mode 100644
index 0000000..4bf620a
--- /dev/null
+++ b/manager-solution/manager/config_model.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+import json
+from typing import Any, Dict, Iterable, Optional
+
+DEFAULTS: Dict[str, Any] = {
+ "container_prefix": "fis-",
+ "environment": {},
+ "volumes": {},
+ "mem_limit": None,
+ "memswap_limit": None,
+ "privileged": False,
+ "tmpfs": {},
+ "dns": [],
+ "extra_hosts": {},
+}
+
+
+def merge_dict(base: Optional[Dict[str, Any]], override: Optional[Dict[str, Any]]) -> Dict[str, Any]:
+ result = dict(base or {})
+ result.update(override or {})
+ return result
+
+
+def resolve_placeholders(value: Any, variables: Dict[str, Any]) -> Any:
+ if isinstance(value, str):
+ return value.format(**variables)
+ if isinstance(value, dict):
+ return {
+ resolve_placeholders(key, variables): resolve_placeholders(item, variables)
+ for key, item in value.items()
+ }
+ if isinstance(value, list):
+ return [resolve_placeholders(item, variables) for item in value]
+ return value
+
+
+def merge_defaults(config: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
+ defaults = merge_dict(DEFAULTS, config.get("defaults"))
+ merged = dict(user)
+
+ merged["id"] = str(user["id"])
+ merged["port"] = int(user["port"])
+ variables = {"id": merged["id"], "port": merged["port"]}
+
+ merged["environment"] = merge_dict(
+ resolve_placeholders(defaults.get("environment", {}), variables),
+ resolve_placeholders(user.get("environment"), variables),
+ )
+ merged["environment"].setdefault("CUSTOM_USER", merged["id"])
+ merged["volumes"] = merge_dict(
+ resolve_placeholders(defaults.get("volumes", {}), variables),
+ resolve_placeholders(user.get("volumes"), variables),
+ )
+ merged["extra_hosts"] = merge_dict(
+ resolve_placeholders(defaults.get("extra_hosts", {}), variables),
+ resolve_placeholders(user.get("extra_hosts"), variables),
+ )
+ merged["tmpfs"] = merge_dict(
+ resolve_placeholders(defaults.get("tmpfs", {}), variables),
+ resolve_placeholders(user.get("tmpfs"), variables),
+ )
+ merged["dns"] = resolve_placeholders(user.get("dns", defaults.get("dns", [])), variables)
+ merged["mem_limit"] = user.get("mem_limit", defaults.get("mem_limit"))
+ merged["memswap_limit"] = user.get("memswap_limit", defaults.get("memswap_limit"))
+ merged["privileged"] = user.get("privileged", defaults.get("privileged", False))
+
+ if not merged.get("container_name"):
+ prefix = defaults.get("container_prefix", "fis-")
+ merged["container_name"] = f"{prefix}{merged['id']}"
+
+ if not merged["volumes"]:
+ merged["volumes"] = {f"v-conf-{merged['id']}": "/config"}
+
+ return merged
+
+
+def validate_users(users: Iterable[Dict[str, Any]]) -> None:
+ seen_ids = set()
+ seen_ports = set()
+ seen_names = set()
+
+ for user in users:
+ uid = user["id"]
+ port = user["port"]
+ name = user["container_name"]
+ mount_targets = list((user.get("volumes") or {}).values())
+
+ if uid in seen_ids:
+ raise ValueError(f"Duplicate user id: {uid}")
+ if port in seen_ports:
+ raise ValueError(f"Duplicate port: {port}")
+ if name in seen_names:
+ raise ValueError(f"Duplicate container_name: {name}")
+ if not user["volumes"]:
+ raise ValueError(f"User {uid} must define at least one volume")
+ if len(mount_targets) != len(set(mount_targets)):
+ raise ValueError(f"User {uid} has duplicate volume mount targets")
+ if "PASSWORD" not in user["environment"] or not user["environment"]["PASSWORD"]:
+ raise ValueError(f"User {uid} must define environment.PASSWORD")
+
+ seen_ids.add(uid)
+ seen_ports.add(port)
+ seen_names.add(name)
+
+
+def normalize_config(raw: Dict[str, Any]) -> Dict[str, Any]:
+ raw_users = raw.get("users", [])
+ if not raw_users:
+ raise ValueError("Configuration must contain at least one user")
+
+ users = [merge_defaults(raw, user) for user in raw_users]
+ validate_users(users)
+
+ return {"defaults": merge_dict(DEFAULTS, raw.get("defaults")), "users": users}
+
+
+def compact_config(raw: Dict[str, Any]) -> Dict[str, Any]:
+ normalized = normalize_config(raw)
+ defaults = merge_dict(DEFAULTS, raw.get("defaults"))
+ compact_users = []
+
+ for user in normalized["users"]:
+ user_id = user["id"]
+ variables = {"id": user_id, "port": user["port"]}
+ resolved_default_env = resolve_placeholders(defaults.get("environment", {}), variables)
+ resolved_default_volumes = resolve_placeholders(defaults.get("volumes", {}), variables)
+ resolved_default_extra_hosts = resolve_placeholders(defaults.get("extra_hosts", {}), variables)
+ resolved_default_tmpfs = resolve_placeholders(defaults.get("tmpfs", {}), variables)
+ resolved_default_dns = resolve_placeholders(defaults.get("dns", []), variables)
+ default_container_name = f"{defaults.get('container_prefix', 'fis-')}{user_id}"
+
+ compact_user: Dict[str, Any] = {
+ "id": user_id,
+ "port": user["port"],
+ }
+
+ if user.get("container_name") and user["container_name"] != default_container_name:
+ compact_user["container_name"] = user["container_name"]
+
+ environment = {
+ key: value
+ for key, value in (user.get("environment") or {}).items()
+ if resolved_default_env.get(key) != value and not (key == "CUSTOM_USER" and value == user_id)
+ }
+ if environment:
+ compact_user["environment"] = environment
+
+ volumes = {
+ key: value
+ for key, value in (user.get("volumes") or {}).items()
+ if resolved_default_volumes.get(key) != value
+ }
+ if volumes:
+ compact_user["volumes"] = volumes
+
+ extra_hosts = {
+ key: value
+ for key, value in (user.get("extra_hosts") or {}).items()
+ if resolved_default_extra_hosts.get(key) != value
+ }
+ if extra_hosts:
+ compact_user["extra_hosts"] = extra_hosts
+
+ tmpfs = {
+ key: value
+ for key, value in (user.get("tmpfs") or {}).items()
+ if resolved_default_tmpfs.get(key) != value
+ }
+ if tmpfs:
+ compact_user["tmpfs"] = tmpfs
+
+ if user.get("dns") != resolved_default_dns and user.get("dns") is not None:
+ compact_user["dns"] = user["dns"]
+ if user.get("mem_limit") != defaults.get("mem_limit"):
+ compact_user["mem_limit"] = user.get("mem_limit")
+ if user.get("memswap_limit") != defaults.get("memswap_limit"):
+ compact_user["memswap_limit"] = user.get("memswap_limit")
+ if user.get("privileged") != defaults.get("privileged", False):
+ compact_user["privileged"] = user.get("privileged", False)
+
+ compact_users.append(compact_user)
+
+ return {"defaults": defaults, "users": compact_users}
+
+
+def load_config(path: str) -> Dict[str, Any]:
+ with open(path, encoding="utf-8") as handle:
+ raw = json.load(handle)
+ return normalize_config(raw)
diff --git a/manager-solution/manager/manager.py b/manager-solution/manager/manager.py
index 1548e7b..3c2f0a5 100644
--- a/manager-solution/manager/manager.py
+++ b/manager-solution/manager/manager.py
@@ -12,9 +12,10 @@ import os
import socket
import threading
import time
-from typing import Any, Dict, Iterable, Optional
+from typing import Any, Dict, Optional
import docker
+from config_model import load_config
CONFIG_PATH = os.getenv("CONFIG_PATH", "config.json")
IMAGE_NAME = os.getenv("IMAGE_NAME", "fis")
@@ -23,23 +24,12 @@ GATEWAY_PORT = int(os.getenv("GATEWAY_PORT", "0"))
INACTIVITY_SECONDS = int(os.getenv("INACTIVITY_SECONDS", "10"))
IDLE_CHECK_INTERVAL = int(os.getenv("IDLE_CHECK_INTERVAL", "10"))
STARTUP_TIMEOUT_SECONDS = int(os.getenv("STARTUP_TIMEOUT_SECONDS", "60"))
-STARTUP_STABILIZATION_SECONDS = float(os.getenv("STARTUP_STABILIZATION_SECONDS", "5"))
TCP_CONNECT_TIMEOUT_SECONDS = float(os.getenv("TCP_CONNECT_TIMEOUT_SECONDS", "1.5"))
+HTTP_READY_PATH = os.getenv("HTTP_READY_PATH", "/")
+HTTP_READY_TIMEOUT_SECONDS = float(os.getenv("HTTP_READY_TIMEOUT_SECONDS", "1.5"))
INITIAL_REQUEST_TIMEOUT_SECONDS = float(os.getenv("INITIAL_REQUEST_TIMEOUT_SECONDS", "3"))
MAX_REQUEST_HEADER_BYTES = int(os.getenv("MAX_REQUEST_HEADER_BYTES", "65536"))
-DEFAULTS: Dict[str, Any] = {
- "container_prefix": "fis-",
- "environment": {},
- "volumes": {},
- "mem_limit": None,
- "memswap_limit": None,
- "privileged": False,
- "tmpfs": {},
- "dns": [],
- "extra_hosts": {},
-}
-
_docker_client = None
user_activity: Dict[str, float] = {}
users_by_id: Dict[str, Dict[str, Any]] = {}
@@ -69,104 +59,6 @@ def get_user_container_lock(user_id: str) -> threading.Lock:
return user_container_locks[user_id]
-def merge_dict(base: Dict[str, Any], override: Optional[Dict[str, Any]]) -> Dict[str, Any]:
- result = dict(base or {})
- result.update(override or {})
- return result
-
-
-def resolve_placeholders(value: Any, variables: Dict[str, Any]) -> Any:
- if isinstance(value, str):
- return value.format(**variables)
- if isinstance(value, dict):
- return {
- resolve_placeholders(key, variables): resolve_placeholders(item, variables)
- for key, item in value.items()
- }
- if isinstance(value, list):
- return [resolve_placeholders(item, variables) for item in value]
- return value
-
-
-def merge_defaults(config: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]:
- defaults = merge_dict(DEFAULTS, config.get("defaults"))
- merged = dict(user)
-
- merged["id"] = str(user["id"])
- merged["port"] = int(user["port"])
- variables = {"id": merged["id"], "port": merged["port"]}
-
- merged["environment"] = merge_dict(
- resolve_placeholders(defaults.get("environment", {}), variables),
- resolve_placeholders(user.get("environment"), variables),
- )
- merged["volumes"] = merge_dict(
- resolve_placeholders(defaults.get("volumes", {}), variables),
- resolve_placeholders(user.get("volumes"), variables),
- )
- merged["extra_hosts"] = merge_dict(
- resolve_placeholders(defaults.get("extra_hosts", {}), variables),
- resolve_placeholders(user.get("extra_hosts"), variables),
- )
- merged["tmpfs"] = merge_dict(
- resolve_placeholders(defaults.get("tmpfs", {}), variables),
- resolve_placeholders(user.get("tmpfs"), variables),
- )
- merged["dns"] = resolve_placeholders(user.get("dns", defaults.get("dns", [])), variables)
- merged["mem_limit"] = user.get("mem_limit", defaults.get("mem_limit"))
- merged["memswap_limit"] = user.get("memswap_limit", defaults.get("memswap_limit"))
- merged["privileged"] = user.get("privileged", defaults.get("privileged", False))
-
- if not merged.get("container_name"):
- prefix = defaults.get("container_prefix", "fis-")
- merged["container_name"] = f"{prefix}{merged['id']}"
-
- if not merged["volumes"]:
- merged["volumes"] = {f"v-conf-{merged['id']}": "/config"}
-
- return merged
-
-
-def validate_users(users: Iterable[Dict[str, Any]]) -> None:
- seen_ids = set()
- seen_ports = set()
- seen_names = set()
-
- for user in users:
- uid = user["id"]
- port = user["port"]
- name = user["container_name"]
-
- if uid in seen_ids:
- raise ValueError(f"Duplicate user id: {uid}")
- if port in seen_ports:
- raise ValueError(f"Duplicate port: {port}")
- if name in seen_names:
- raise ValueError(f"Duplicate container_name: {name}")
- if not user["volumes"]:
- raise ValueError(f"User {uid} must define at least one volume")
- if "PASSWORD" not in user["environment"] or not user["environment"]["PASSWORD"]:
- raise ValueError(f"User {uid} must define environment.PASSWORD")
-
- seen_ids.add(uid)
- seen_ports.add(port)
- seen_names.add(name)
-
-
-def load_config(path: str = CONFIG_PATH) -> Dict[str, Any]:
- with open(path, encoding="utf-8") as handle:
- raw = json.load(handle)
-
- raw_users = raw.get("users", [])
- if not raw_users:
- raise ValueError("Configuration must contain at least one user")
-
- users = [merge_defaults(raw, user) for user in raw_users]
- validate_users(users)
-
- return {"defaults": merge_dict(DEFAULTS, raw.get("defaults")), "users": users}
-
-
def get_container_ip(container_name: str) -> Optional[str]:
"""Get IP address of a running container."""
try:
@@ -193,6 +85,30 @@ def is_port_open(host: str, port: int, timeout_seconds: float = TCP_CONNECT_TIME
return False
+def is_http_ready(host: str, port: int, path: str = HTTP_READY_PATH) -> bool:
+ try:
+ with socket.create_connection((host, port), timeout=HTTP_READY_TIMEOUT_SECONDS) as sock:
+ sock.settimeout(HTTP_READY_TIMEOUT_SECONDS)
+ request = (
+ f"GET {path or '/'} HTTP/1.1\r\n"
+ f"Host: {host}:{port}\r\n"
+ "Connection: close\r\n"
+ "\r\n"
+ )
+ sock.sendall(request.encode("ascii"))
+ response = sock.recv(4096)
+ except OSError:
+ return False
+
+ status_line = response.split(b"\r\n", 1)[0]
+ parts = status_line.split()
+ if len(parts) < 2 or not parts[1].isdigit():
+ return False
+
+ status_code = int(parts[1])
+ return status_code in {200, 204, 301, 302, 303, 307, 308, 401, 403}
+
+
async def read_initial_http_request(reader) -> bytes:
buffer = bytearray()
while len(buffer) < MAX_REQUEST_HEADER_BYTES:
@@ -300,20 +216,16 @@ def extract_user_from_http_request(data: bytes, known_users: Dict[str, Dict[str,
def wait_until_ready(container_name: str) -> bool:
- stabilized = False
+ waiting_for_http = False
deadline = time.time() + STARTUP_TIMEOUT_SECONDS
while time.time() < deadline:
ip = get_container_ip(container_name)
if ip and is_port_open(ip, APP_PORT):
- if STARTUP_STABILIZATION_SECONDS > 0 and not stabilized:
- log(
- f"[{container_name}] Port {APP_PORT} is open, waiting "
- f"{STARTUP_STABILIZATION_SECONDS}s for backend stabilization"
- )
- time.sleep(STARTUP_STABILIZATION_SECONDS)
- stabilized = True
- continue
- return True
+ if is_http_ready(ip, APP_PORT):
+ return True
+ if not waiting_for_http:
+ log(f"[{container_name}] Port {APP_PORT} is open, waiting for HTTP readiness")
+ waiting_for_http = True
time.sleep(0.5)
return False
@@ -364,7 +276,7 @@ def start_user_container(user: Dict[str, Any]):
extra_hosts=user.get("extra_hosts") or None,
tty=True,
stdin_open=True,
- restart_policy={"Name": "no"},
+ restart_policy={"Name": "always"},
)
except docker.errors.APIError as exc:
if "already in use" not in str(exc).lower():
diff --git a/manager-solution/manager/test_manager.py b/manager-solution/manager/test_manager.py
deleted file mode 100644
index 71f3353..0000000
--- a/manager-solution/manager/test_manager.py
+++ /dev/null
@@ -1,236 +0,0 @@
-import importlib.util
-import json
-import sys
-import tempfile
-import types
-import unittest
-from pathlib import Path
-
-
-class FakeNotFound(Exception):
- pass
-
-
-class FakeAPIError(Exception):
- pass
-
-
-class FakeContainer:
- def __init__(self, status="running"):
- self.stop_calls = []
- self.start_calls = 0
- self.reload_calls = 0
- self.status = status
-
- def reload(self):
- self.reload_calls += 1
-
- def stop(self, timeout=10):
- self.stop_calls.append(timeout)
-
- def start(self):
- self.start_calls += 1
- self.status = "running"
-
-
-class FakeContainers:
- def __init__(self, container=None):
- self.container = container or FakeContainer()
- self.requested_names = []
- self.run_calls = []
- self.raise_not_found = False
- self.raise_api_error_on_run = None
-
- def get(self, name):
- self.requested_names.append(name)
- if self.raise_not_found:
- self.raise_not_found = False
- raise FakeNotFound(name)
- return self.container
-
- def run(self, image, **kwargs):
- self.run_calls.append((image, kwargs))
- if self.raise_api_error_on_run is not None:
- raise self.raise_api_error_on_run
- return self.container
-
-
-class FakeVolumes:
- def __init__(self):
- self.created = []
- self.existing = set()
-
- def get(self, name):
- if name not in self.existing:
- raise FakeNotFound(name)
- return name
-
- def create(self, name):
- self.created.append(name)
- self.existing.add(name)
- return name
-
-
-class FakeDockerClient:
- def __init__(self):
- self.containers = FakeContainers()
- self.volumes = FakeVolumes()
-
-
-def load_manager_module():
- fake_client = FakeDockerClient()
- fake_docker = types.SimpleNamespace(
- from_env=lambda: fake_client,
- errors=types.SimpleNamespace(NotFound=FakeNotFound, APIError=FakeAPIError),
- )
- sys.modules["docker"] = fake_docker
-
- module_path = Path(__file__).with_name("manager.py")
- spec = importlib.util.spec_from_file_location("manager_under_test", module_path)
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- module._docker_client = fake_client
- return module, fake_client
-
-
-class ManagerConfigTests(unittest.TestCase):
- def setUp(self):
- self.manager, self.fake_client = load_manager_module()
-
- def write_config(self, payload):
- temp = tempfile.NamedTemporaryFile("w", delete=False, encoding="utf-8")
- with temp:
- json.dump(payload, temp)
- return temp.name
-
- def test_load_config_applies_defaults_and_placeholders(self):
- path = self.write_config(
- {
- "defaults": {
- "container_prefix": "fis-",
- "volumes": {"v-conf-{id}": "/config"},
- "environment": {"PUID": "1000", "PGID": "1000"},
- "mem_limit": "3g",
- "privileged": True,
- },
- "users": [
- {
- "id": "mario",
- "port": 3101,
- "environment": {"PASSWORD": "secret"},
- }
- ],
- }
- )
-
- config = self.manager.load_config(path)
- user = config["users"][0]
-
- self.assertEqual(user["container_name"], "fis-mario")
- self.assertEqual(user["volumes"], {"v-conf-mario": "/config"})
- self.assertEqual(user["environment"]["PUID"], "1000")
- self.assertEqual(user["environment"]["PASSWORD"], "secret")
- self.assertEqual(user["mem_limit"], "3g")
- self.assertTrue(user["privileged"])
-
- def test_load_config_rejects_duplicate_ports(self):
- path = self.write_config(
- {
- "users": [
- {"id": "user1", "port": 3001, "environment": {"PASSWORD": "a"}},
- {"id": "user2", "port": 3001, "environment": {"PASSWORD": "b"}},
- ]
- }
- )
-
- with self.assertRaisesRegex(ValueError, "Duplicate port"):
- self.manager.load_config(path)
-
- def test_load_config_requires_password(self):
- path = self.write_config({"users": [{"id": "user1", "port": 3001}]})
-
- with self.assertRaisesRegex(ValueError, "PASSWORD"):
- self.manager.load_config(path)
-
- def test_stop_user_container_uses_configured_container_name(self):
- user = {"id": "user1", "container_name": "custom-name"}
-
- self.manager.stop_user_container(user)
-
- self.assertEqual(self.fake_client.containers.requested_names, ["custom-name"])
- self.assertEqual(self.fake_client.containers.container.stop_calls, [10])
-
- def test_start_user_container_recovers_from_concurrent_create_conflict(self):
- container = FakeContainer(status="created")
- self.fake_client.containers.container = container
- self.fake_client.containers.raise_not_found = True
- self.fake_client.containers.raise_api_error_on_run = FakeAPIError(
- 'Conflict. The container name "/fis-user1" is already in use'
- )
- self.manager.wait_until_ready = lambda name: True
-
- user = {
- "id": "user1",
- "container_name": "fis-user1",
- "volumes": {"v-conf-user1": "/config"},
- "environment": {"PASSWORD": "secret"},
- "privileged": False,
- }
-
- self.manager.start_user_container(user)
-
- self.assertEqual(self.fake_client.containers.requested_names, ["fis-user1", "fis-user1"])
- self.assertEqual(container.start_calls, 1)
- self.assertEqual(self.fake_client.volumes.created, ["v-conf-user1"])
-
- def test_extract_user_from_host_header(self):
- request = (
- b"GET / HTTP/1.1\r\n"
- b"Host: user2.example.local\r\n"
- b"Connection: keep-alive\r\n"
- b"\r\n"
- )
- users = {"user2": {"id": "user2"}}
-
- user, rewritten, redirect_target = self.manager.extract_user_from_http_request(request, users)
-
- self.assertEqual(user["id"], "user2")
- self.assertEqual(rewritten, request)
- self.assertIsNone(redirect_target)
-
- def test_extract_user_from_path_returns_redirect_target(self):
- request = (
- b"GET /user1/websockify?token=abc HTTP/1.1\r\n"
- b"Host: localhost:8080\r\n"
- b"Upgrade: websocket\r\n"
- b"\r\n"
- )
- users = {"user1": {"id": "user1"}}
-
- user, rewritten, redirect_target = self.manager.extract_user_from_http_request(request, users)
-
- self.assertIsNone(user)
- self.assertEqual(rewritten, request)
- self.assertEqual(redirect_target, "http://user1.localhost:8080/websockify?token=abc")
-
- def test_wait_until_ready_applies_stabilization_delay(self):
- sleep_calls = []
- clock = {"now": 0.0}
-
- self.manager.STARTUP_TIMEOUT_SECONDS = 10
- self.manager.STARTUP_STABILIZATION_SECONDS = 5
- self.manager.get_container_ip = lambda name: "10.0.0.2"
- self.manager.is_port_open = lambda host, port: True
- self.manager.time = types.SimpleNamespace(
- time=lambda: clock["now"],
- sleep=lambda seconds: (sleep_calls.append(seconds), clock.__setitem__("now", clock["now"] + seconds)),
- )
-
- ready = self.manager.wait_until_ready("fis-user1")
-
- self.assertTrue(ready)
- self.assertEqual(sleep_calls, [5])
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/manager-solution/manager/validate_config.py b/manager-solution/manager/validate_config.py
new file mode 100644
index 0000000..3d09516
--- /dev/null
+++ b/manager-solution/manager/validate_config.py
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+import json
+import os
+import sys
+
+from config_model import load_config
+
+
+def main() -> int:
+ path = sys.argv[1] if len(sys.argv) > 1 else os.getenv("CONFIG_PATH", "config.json")
+ config = load_config(path)
+ json.dump(config, sys.stdout, indent=2)
+ sys.stdout.write("\n")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/maschera.zip b/maschera.zip
new file mode 100644
index 0000000..ed1d2b3
Binary files /dev/null and b/maschera.zip differ
diff --git a/prova-docker-compose.yml.txt b/prova-docker-compose.yml.txt
deleted file mode 100755
index 26b361a..0000000
--- a/prova-docker-compose.yml.txt
+++ /dev/null
@@ -1,59 +0,0 @@
-version: '3.8'
-
-services:
- fis:
- image: fis
- container_name: fis
- ports:
- - "3000:3000"
- environment:
- - PUID=1000
- - PGID=1000
- - CUSTOM_USER=tomas
- - PASSWORD=tommal
- - LC_ALL=it_IT.UTF-8
- - TZ=Europe/Rome
- mem_limit: 3g
- memswap_limit: 5g
- privileged: true
- tmpfs:
- - /tmp:exec,mode=777
- dns:
- - 10.100.0.8
- - 10.200.100.1
- extra_hosts:
- - "scsv28.sanmarco.lan:10.200.100.108"
- volumes:
- - v-conf:/config
-
-volumes:
- v-conf:
-
-
-
-
-
-# ONLY IN PRODUCTION
-#version: '3.8'
-#
-#services:
-# fis:
-# image: fis
-# container_name: fis
-# ports:
-# - "3003:3000"
-# environment:
-# - PUID=1000
-# - PGID=1000
-# - PASSWORD=tommal
-# mem_limit: 700m
-# memswap_limit: 2g
-# privileged: true
-# volumes:
-# - v-fis:/app/Fiscality
-# - v-config:/config
-#
-#volumes:
-# v-fis:
-# v-config:
-#
diff --git a/stop.sh b/uninstall.sh
similarity index 100%
rename from stop.sh
rename to uninstall.sh