diff --git a/packages/secubox-nextcloud/api/main.py b/packages/secubox-nextcloud/api/main.py index 97b538d0..0e217d4b 100644 --- a/packages/secubox-nextcloud/api/main.py +++ b/packages/secubox-nextcloud/api/main.py @@ -16,6 +16,24 @@ from secubox_core.config import get_config app = FastAPI(title="SecuBox Nextcloud") config = get_config("nextcloud") + +def _public_base_url(ssl_domain: str, domain: str, http_port: int) -> str: + """Return the URL a client/device uses to reach Nextcloud. + + HAProxy terminates TLS 1.3 in front of every SecuBox vhost, so a real + public domain is always reachable over https. Precedence: an explicit + ssl_domain wins; otherwise the configured public ``domain`` (skipping the + ``cloud.local`` placeholder); only a bare host with no real domain falls + back to the container port. Never emit ``localhost`` when a domain exists — + that is unreachable from a phone/desktop client (the bug this fixes). + """ + if ssl_domain: + return f"https://{ssl_domain}" + if "." in domain and domain != "cloud.local": + return f"https://{domain}" + return f"http://localhost:{http_port}" + + LXC_NAME = config.get("container_name", "nextcloud") LXC_PATH = Path(config.get("lxc_path", "/data/lxc")) DATA_PATH = Path(config.get("data_path", "/data/volumes/nextcloud")) @@ -158,8 +176,7 @@ async def status(): "disk_used": disk_used, # Public URL from the real domain (not localhost) so the dashboard links # somewhere reachable; falls back to the container port for a bare host. - "web_url": f"https://{domain}" if "." in domain and domain != "cloud.local" - else f"http://localhost:{http_port}", + "web_url": _public_base_url(config.get("ssl_domain", ""), domain, http_port), "ssl_enabled": config.get("ssl_enabled", False), "container_name": LXC_NAME, } @@ -443,12 +460,10 @@ def restore_backup(name: str): async def get_connections(): """Get connection URLs (CalDAV, CardDAV, WebDAV)""" http_port = config.get("http_port", 8080) - ssl_enabled = config.get("ssl_enabled", False) + domain = config.get("domain", "cloud.local") ssl_domain = config.get("ssl_domain", "") - base_url = f"http://localhost:{http_port}" - if ssl_enabled and ssl_domain: - base_url = f"https://{ssl_domain}" + base_url = _public_base_url(ssl_domain, domain, http_port) return { "base_url": base_url, diff --git a/packages/secubox-nextcloud/debian/changelog b/packages/secubox-nextcloud/debian/changelog index b7217713..a96f0668 100644 --- a/packages/secubox-nextcloud/debian/changelog +++ b/packages/secubox-nextcloud/debian/changelog @@ -1,3 +1,15 @@ +secubox-nextcloud (1.5.1-1~bookworm1) bookworm; urgency=medium + + * Fix connection URLs showing http://localhost:8080 instead of the real + public domain. /connections only checked ssl_domain (unset in + secubox.conf [nextcloud], which carries `domain`) and fell back to + localhost; the panel also hardcoded localhost, ignoring status.web_url. + New pure _public_base_url() helper (ssl_domain > domain > localhost), + used by both /connections and /status; panel now renders status.web_url. + Regression tests added (tests/test_public_base_url.py). + + -- Gerald KERMA Fri, 25 Jul 2026 12:00:00 +0200 + secubox-nextcloud (1.5.0-1~bookworm2) bookworm; urgency=medium * Phase 2: Requires=secubox-core.service -> Wants= on this module's unit diff --git a/packages/secubox-nextcloud/tests/test_public_base_url.py b/packages/secubox-nextcloud/tests/test_public_base_url.py new file mode 100644 index 00000000..677e854d --- /dev/null +++ b/packages/secubox-nextcloud/tests/test_public_base_url.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. + +""" +SecuBox-Deb :: Nextcloud — _public_base_url unit tests + +Regression guard: /connections must never hand a client localhost when a +real public domain is configured (clients reach Nextcloud through HAProxy +TLS 1.3). The helper is pure so it is tested without importing the FastAPI +app (which starts a cache thread + LXC probes at import time). +""" + +import ast +import textwrap +from pathlib import Path + +MAIN = Path(__file__).resolve().parent.parent / "api" / "main.py" + + +def _load_helper(): + """Extract and exec only the _public_base_url function from api/main.py.""" + tree = ast.parse(MAIN.read_text()) + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name == "_public_base_url": + src = textwrap.dedent(ast.get_source_segment(MAIN.read_text(), node)) + ns: dict = {} + exec(src, ns) + return ns["_public_base_url"] + raise AssertionError("_public_base_url not found in api/main.py") + + +_public_base_url = _load_helper() + + +def test_configured_domain_yields_https_not_localhost(): + # The bug: secubox.conf [nextcloud] has domain but no ssl_domain. + assert _public_base_url("", "nc.gk2.secubox.in", 8080) == "https://nc.gk2.secubox.in" + + +def test_ssl_domain_takes_precedence(): + assert _public_base_url("secure.example.com", "nc.gk2.secubox.in", 8080) == \ + "https://secure.example.com" + + +def test_placeholder_domain_falls_back_to_localhost(): + assert _public_base_url("", "cloud.local", 8080) == "http://localhost:8080" + + +def test_bare_host_without_dot_falls_back_to_localhost(): + assert _public_base_url("", "nextcloud", 9000) == "http://localhost:9000" diff --git a/packages/secubox-nextcloud/www/nextcloud/index.html b/packages/secubox-nextcloud/www/nextcloud/index.html index 40163589..1273bd4d 100644 --- a/packages/secubox-nextcloud/www/nextcloud/index.html +++ b/packages/secubox-nextcloud/www/nextcloud/index.html @@ -237,9 +237,11 @@ document.getElementById('data-size').textContent = status.disk_used || '-'; document.getElementById('version').textContent = status.version || '-'; - const port = status.http_port || 8080; - document.getElementById('web-url').textContent = `http://localhost:${port}`; - document.getElementById('web-url').href = `http://localhost:${port}`; + // Use the public URL the API resolves (real domain behind HAProxy + // TLS), not a hardcoded localhost the client can't reach. + const webUrl = status.web_url || `http://localhost:${status.http_port || 8080}`; + document.getElementById('web-url').textContent = webUrl; + document.getElementById('web-url').href = webUrl; } async function loadStorage() {