diff --git a/.env.example b/.env.example index e378df8..a598bf4 100644 --- a/.env.example +++ b/.env.example @@ -10,5 +10,8 @@ LOCAL_ADMIN_PASSWORD=CHANGE_ME_MIN_12_CHARS BACKUP_INTERVAL_HOURS=8 BACKUP_RETENTION_DAYS=3 -# Optional host port for the callback-only Docker Compose profile. +# Dedicated callback-only listener. It starts with the normal Compose stack. +# Use 127.0.0.1 when a reverse proxy runs on the same host; use a specific LAN/DMZ +# address or 0.0.0.0 only when the listener must be reachable directly. +CALLBACK_BIND_IP=127.0.0.1 CALLBACK_PORT=8090 diff --git a/README.md b/README.md index 57db8a0..27060e3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AssetManager 0.5.5.64 +# AssetManager 0.5.5.65 AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. It provides asset inventory, software inventory, remote job execution, reporting, privacy/retention documentation, and optional integration with MeshCentral. @@ -81,7 +81,7 @@ data/ └── scripts/ deployed scripts ``` -These directories and `.env` may contain credentials, personal data, or internal operational information and must not be committed to a public repository. +Persistent runtime data and local secrets are intentionally kept outside the application image. See [SECURITY.md](SECURITY.md) and [docs/INSTALLATION.md](docs/INSTALLATION.md) for deployment guidance. ## Authentication @@ -95,6 +95,13 @@ MeshCentral integration is optional. AssetManager uses MeshCtrl for supported de MeshCentral and MeshCtrl are independent third-party projects and are not part of this repository. They are installed as dependencies during the Docker build. + +## Dedicated callback listener + +Docker deployments start a separate callback-only service together with the main application. The listener exposes only the software-job callback endpoint and its health check; the AssetManager web interface is not available on that port. `CALLBACK_BIND_IP` and `CALLBACK_PORT` control the host-side listener, while the public FQDN used by clients is configured in **Software and Jobs → Settings**. + +The settings page provides separate health checks for the internal callback service and the configured/public callback address. See [`docs/INSTALLATION.md`](docs/INSTALLATION.md) for reverse-proxy and DMZ examples. + ## Privacy and retention The application includes a **Security & Privacy** section for documenting processing activities, data categories, retention periods, recipients, international transfers, technical and organizational measures, and deletion checks. Reports can be exported as text, HTML, or Excel. @@ -115,7 +122,7 @@ Historical release notes are available in [`docs/version-history/`](docs/version ## Security -Do not commit credentials, tokens, private keys, personal data, runtime databases, logs, backups, or internal deployment details. Security reporting guidance is available in [SECURITY.md](SECURITY.md). +Keep deployment secrets and persistent runtime data outside source control. Security reporting and deployment guidance is available in [SECURITY.md](SECURITY.md). ## Third-party components diff --git a/SECURITY.md b/SECURITY.md index bb75e3c..f816efb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,3 +19,9 @@ A report should include: - relevant log excerpts with all secrets removed Only test systems for which you have explicit authorization. + +## Deployment secrets and runtime data + +Keep `.env` and persistent runtime directories such as `data/config`, `data/postgres`, `data/uploads`, `data/logs`, and `data/backups` outside source control. They are deployment data rather than application source and can contain credentials or operational information. + +For Internet-facing callbacks, publish only the dedicated callback listener through the firewall or reverse proxy. The normal AssetManager web port should remain on the intended administrative network. diff --git a/VERSION b/VERSION index 4d1b92d..f8c741a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.5.64 +0.5.5.65 diff --git a/app/i18n.py b/app/i18n.py index 9929fe5..ffbc862 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -52,8 +52,8 @@ BASE_TRANSLATIONS = { "fields.data_type_user_reference": ("User reference", "Benutzer-Verweis"), "fields.data_type_status": ("Status", "Status"), "software.settings.dedicated_listener": ("Dedicated callback listener", "Separater Callback-Zugang"), -"software.settings.dedicated_listener_optional": ("Optional – callback API only", "Optional – nur Callback-API"), -"software.settings.dedicated_listener_help": ("For Internet/DMZ scenarios, the callback API can run as a separate Docker Compose service on host port {port}. This listener exposes only the callback endpoint and health check, never the AssetManager web interface. Configure the public FQDN above as the callback base URL.", "Für Internet-/DMZ-Szenarien kann die Callback-API als eigener Docker-Compose-Dienst auf Host-Port {port} laufen. Dieser Zugang stellt ausschließlich den Callback-Endpunkt und den Healthcheck bereit, niemals die AssetManager-Weboberfläche. Trage oben den öffentlichen FQDN als Callback-Basisadresse ein."), +"software.settings.dedicated_listener_active": ("Starts automatically with AssetManager", "Startet automatisch mit AssetManager"), +"software.settings.dedicated_listener_help": ("The callback-only service listens on {bind_ip}:{port}. It starts with the normal Docker Compose stack and exposes only the callback endpoint and health check, never the AssetManager web interface. Configure the public FQDN above as the callback base URL.", "Der reine Callback-Dienst lauscht auf {bind_ip}:{port}. Er startet zusammen mit dem normalen Docker-Compose-Stack und stellt ausschließlich den Callback-Endpunkt und den Healthcheck bereit, niemals die AssetManager-Weboberfläche. Trage oben den öffentlichen FQDN als Callback-Basisadresse ein."), "profile.title": ("User profile", "Benutzerprofil"), "profile.username": ("Username", "Benutzername"), "profile.display_name": ("Display name", "Anzeigename"), "profile.email": ("Email", "E-Mail"), "profile.source": ("Source", "Quelle"), "profile.last_login": ("Last sign-in", "Letzte Anmeldung"), "profile.change_password": ("Change password", "Passwort ändern"), @@ -833,6 +833,15 @@ BASE_TRANSLATIONS.update({ "software.settings.test_title": ("Callback test and live debug log", "Callback-Test und Live-Debugprotokoll"), "software.settings.test_help": ("The AssetManager calls the configured health endpoint and records DNS resolution, connection, HTTP status and response.", "Der AssetManager ruft den konfigurierten Health-Endpunkt auf und protokolliert DNS-Auflösung, Verbindung, HTTP-Status und Antwort."), "software.settings.run_test": ("Test callback", "Callback testen"), + "software.settings.run_internal_test": ("Test internal health check", "Internen Health-Check testen"), + "software.settings.run_external_test": ("Test public health check", "Öffentlichen Health-Check testen"), + "software.settings.internal_health_url": ("Internal callback health URL", "Interne Callback-Health-URL"), + "software.settings.test_result": ("Health-check result", "Health-Check-Ergebnis"), + "software.settings.test_reachable": ("Reachable", "Erreichbar"), + "software.settings.test_unreachable": ("Not reachable", "Nicht erreichbar"), + "software.settings.test_kind_internal": ("Internal callback service", "Interner Callback-Dienst"), + "software.settings.test_kind_external": ("Configured/public callback address", "Konfigurierte/öffentliche Callback-Adresse"), + "software.settings.response_time": ("Response time", "Antwortzeit"), "software.settings.log_auto_refresh": ("The log is refreshed automatically every three seconds.", "Das Protokoll wird automatisch alle drei Sekunden aktualisiert."), "software.settings.clear_log": ("Clear log", "Protokoll leeren"), "software.settings.no_log": ("No diagnostic entries yet.", "Noch keine Diagnoseeinträge vorhanden."), diff --git a/app/main.py b/app/main.py index fe78523..d53c08a 100644 --- a/app/main.py +++ b/app/main.py @@ -7778,15 +7778,24 @@ def settings_software_page(request: Request): _require_admin(request) settings = _software_settings() callback_base = str(settings.get("callback_base_url", "") or "").strip().rstrip("/") + callback_internal_base = str(os.getenv("CALLBACK_INTERNAL_BASE_URL", "http://callback:8001") or "http://callback:8001").strip().rstrip("/") return templates.TemplateResponse( "settings_software.html", { "request": request, "software_settings": settings, "callback_health_url": f"{callback_base}/api/software-callback/health" if callback_base else "", + "callback_internal_health_url": f"{callback_internal_base}/api/software-callback/health", + "callback_test_result": request.query_params.get("callback_test_result", ""), + "callback_test_kind": request.query_params.get("callback_test_kind", ""), + "callback_test_status": request.query_params.get("callback_test_status", ""), + "callback_test_ms": request.query_params.get("callback_test_ms", ""), + "callback_test_url": request.query_params.get("callback_test_url", ""), + "callback_test_message": request.query_params.get("callback_test_message", ""), "debug_log": _software_debug_tail(), "debug_log_path": str(SOFTWARE_CALLBACK_DEBUG_LOG), "callback_dedicated_port": str(os.getenv("CALLBACK_PORT", "8090") or "8090"), + "callback_bind_ip": str(os.getenv("CALLBACK_BIND_IP", "127.0.0.1") or "127.0.0.1"), }, ) @@ -7852,25 +7861,16 @@ def settings_software_save( ) -@app.post("/settings/software/test-callback") -def settings_software_test_callback(request: Request): - _require_admin(request) - settings = _software_settings() - callback_base = str(settings.get("callback_base_url", "") or "").strip().rstrip("/") - if not callback_base: - callback_base = str(request.base_url).rstrip("/") - health_url = f"{callback_base}/api/software-callback/health" - timeout = max(2, min(int(settings.get("callback_test_timeout_seconds", 10) or 10), 60)) - verify_tls = bool(settings.get("callback_test_verify_tls", True)) +def _run_callback_health_test(health_url: str, timeout: int, verify_tls: bool, test_kind: str) -> dict[str, str]: parsed = urllib.parse.urlsplit(health_url) - _software_debug_log("=" * 72, force=True) - _software_debug_log("CALLBACK TEST started", force=True) - _software_debug_log(f"Base URL: {callback_base}", force=True) + _software_debug_log(f"CALLBACK HEALTH TEST started | kind={test_kind}", force=True) _software_debug_log(f"Health URL: {health_url}", force=True) - _software_debug_log(f"Scheme: {parsed.scheme} | Host: {parsed.hostname} | Port: {parsed.port or ('443' if parsed.scheme == 'https' else '80')}", force=True) _software_debug_log(f"Timeout: {timeout}s | Verify TLS: {verify_tls}", force=True) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + return {"ok": "0", "status": "-", "ms": "-", "url": health_url, "message": "Invalid health URL."} + try: dns_started = time.monotonic() addresses = sorted({item[4][0] for item in socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM)}) @@ -7886,37 +7886,68 @@ def settings_software_test_callback(request: Request): try: req = urllib.request.Request( health_url, - headers={"User-Agent": f"AssetManager/{application_version()} CallbackTest"}, + headers={"User-Agent": f"AssetManager/{application_version()} CallbackHealthTest"}, method="GET", ) with urllib.request.urlopen(req, timeout=timeout, context=context) as response: body = response.read(65536).decode("utf-8", errors="replace") elapsed = (time.monotonic() - request_started) * 1000 + status = str(response.status) _software_debug_log(f"HTTP STATUS: {response.status} {response.reason} ({elapsed:.1f} ms)", force=True) - _software_debug_log(f"Content-Type: {response.headers.get('Content-Type', '-')}", force=True) _software_debug_log(f"Response body: {body}", force=True) payload = json.loads(body) if response.status == 200 and payload.get("ok") is True: _software_debug_log("RESULT: Callback health endpoint is reachable.", force=True) - return RedirectResponse( - "/settings/software?toast_success=" + quote(_translate_request(request, "software.settings.test_success", "Callback endpoint is reachable.")), - status_code=303, - ) - raise ValueError("Health response did not contain ok=true") + return {"ok": "1", "status": status, "ms": f"{elapsed:.1f}", "url": health_url, "message": "OK"} + return {"ok": "0", "status": status, "ms": f"{elapsed:.1f}", "url": health_url, "message": "Health response did not contain ok=true."} except urllib.error.HTTPError as exc: + elapsed = (time.monotonic() - request_started) * 1000 body = exc.read(65536).decode("utf-8", errors="replace") _software_debug_log(f"HTTP ERROR: {exc.code} {exc.reason}", force=True) _software_debug_log(f"Response body: {body}", force=True) - message = f"HTTP {exc.code}: {exc.reason}" + return {"ok": "0", "status": str(exc.code), "ms": f"{elapsed:.1f}", "url": health_url, "message": f"HTTP {exc.code}: {exc.reason}"} except Exception as exc: + elapsed = (time.monotonic() - request_started) * 1000 _software_debug_log(f"REQUEST ERROR: {type(exc).__name__}: {exc}", force=True) - message = f"{type(exc).__name__}: {exc}" + return {"ok": "0", "status": "-", "ms": f"{elapsed:.1f}", "url": health_url, "message": f"{type(exc).__name__}: {exc}"} - _software_debug_log("RESULT: Callback health endpoint is not reachable.", force=True) - return RedirectResponse( - "/settings/software?toast_error=" + quote(_translate_request(request, "software.settings.test_failed", "Callback test failed: {error}", error=message)), - status_code=303, - ) + +def _callback_test_redirect(request: Request, result: dict[str, str], test_kind: str) -> RedirectResponse: + params = urllib.parse.urlencode({ + "callback_test_result": result.get("ok", "0"), + "callback_test_kind": test_kind, + "callback_test_status": result.get("status", "-"), + "callback_test_ms": result.get("ms", "-"), + "callback_test_url": result.get("url", ""), + "callback_test_message": result.get("message", ""), + }) + return RedirectResponse(f"/settings/software?{params}", status_code=303) + + +@app.post("/settings/software/test-callback") +@app.post("/settings/software/test-callback-external") +def settings_software_test_callback_external(request: Request): + _require_admin(request) + settings = _software_settings() + callback_base = str(settings.get("callback_base_url", "") or "").strip().rstrip("/") + if not callback_base: + callback_base = str(request.base_url).rstrip("/") + health_url = f"{callback_base}/api/software-callback/health" + timeout = max(2, min(int(settings.get("callback_test_timeout_seconds", 10) or 10), 60)) + verify_tls = bool(settings.get("callback_test_verify_tls", True)) + result = _run_callback_health_test(health_url, timeout, verify_tls, "external") + return _callback_test_redirect(request, result, "external") + + +@app.post("/settings/software/test-callback-internal") +def settings_software_test_callback_internal(request: Request): + _require_admin(request) + settings = _software_settings() + internal_base = str(os.getenv("CALLBACK_INTERNAL_BASE_URL", "http://callback:8001") or "http://callback:8001").strip().rstrip("/") + health_url = f"{internal_base}/api/software-callback/health" + timeout = max(2, min(int(settings.get("callback_test_timeout_seconds", 10) or 10), 60)) + result = _run_callback_health_test(health_url, timeout, True, "internal") + return _callback_test_redirect(request, result, "internal") @app.get("/settings/software/debug-log") diff --git a/app/templates/settings_software.html b/app/templates/settings_software.html index 5a3d1c4..86afd34 100644 --- a/app/templates/settings_software.html +++ b/app/templates/settings_software.html @@ -133,10 +133,10 @@
{{ t('software.settings.dedicated_listener_help', port=callback_dedicated_port) }}
+ {{ t('software.settings.dedicated_listener_active') }} +{{ t('software.settings.dedicated_listener_help', port=callback_dedicated_port, bind_ip=callback_bind_ip) }}
+CALLBACK_BIND_IP={{ callback_bind_ip }}
CALLBACK_PORT={{ callback_dedicated_port }}
- docker compose --profile callback up -d callback
{{ t('software.settings.test_help') }}
- +{{ callback_internal_health_url }}
+ {{ t('software.settings.test_kind_' ~ callback_test_kind) }}
+{{ callback_test_url }}
+