From a3e19bba4eea3980a6b5ab7ee29c21e5fa53f06c Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sat, 4 Jul 2026 09:45:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(vhost):=20redirect=20vhost=20support=20(re?= =?UTF-8?q?direct=5Fto=20=E2=86=92=20301)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds redirect_to to VHostCreate + generate_vhost_config: when set, the vhost is a pure 301 redirect (no proxy, no exposure gate). create_vhost requires a backend only for non-redirect vhosts. Covers the www.example.com → apex pattern (deployed live for www.ganimed.fr). 18 vhost tests pass. --- packages/secubox-vhost/api/main.py | 87 +++++++++++-------- packages/secubox-vhost/debian/changelog | 59 ++----------- .../tests/test_vhost_exposure_wiring.py | 31 +++++++ 3 files changed, 87 insertions(+), 90 deletions(-) diff --git a/packages/secubox-vhost/api/main.py b/packages/secubox-vhost/api/main.py index 54663136..41a90ad0 100644 --- a/packages/secubox-vhost/api/main.py +++ b/packages/secubox-vhost/api/main.py @@ -219,13 +219,14 @@ async def get_access(): class VHostCreate(BaseModel): domain: str - backend: str + backend: str = "" # not required for a pure redirect vhost tls_mode: str = "off" # off, acme, manual websocket: bool = False auth: bool = False auth_user: Optional[str] = None auth_pass: Optional[str] = None ssl_redirect: bool = True + redirect_to: Optional[str] = None # e.g. "https://example.com" → 301 redirect vhost class VHostUpdate(BaseModel): @@ -421,8 +422,13 @@ async def get_vhost(domain: str): def generate_vhost_config(domain: str, backend: str, tls_mode: str, websocket: bool, - ssl_redirect: bool = True, exposure_include: bool = False) -> str: - """Generate nginx vhost configuration""" + ssl_redirect: bool = True, exposure_include: bool = False, + redirect_to: str = "") -> str: + """Generate nginx vhost configuration. + + When redirect_to is set, the vhost is a pure 301 redirect (no backend proxy, + no exposure gate) — e.g. www.example.com → https://example.com. + """ conf = f"""# VHost for {domain} # Generated by SecuBox VHost Manager @@ -462,31 +468,30 @@ server {{ """ conf += "\n location / {\n" - if exposure_include: - # Seeded by ensure_snippet() at create time (Fix 3, ref #793) — only emitted - # when seeding succeeded, so nginx never references a missing include. - conf += f" include /etc/nginx/snippets/exposure/{domain}.conf;\n" - conf += f""" proxy_pass {backend}; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; -""" - - if websocket: - conf += """ proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; -""" - - conf += """ proxy_connect_timeout 60s; - proxy_send_timeout 60s; - proxy_read_timeout 60s; - } - access_log /var/log/nginx/{domain}_access.log; - error_log /var/log/nginx/{domain}_error.log; -}} -""".replace("{domain}", domain) + if redirect_to: + # Pure 301 redirect vhost (no backend). $request_uri preserves path+query. + conf += f" return 301 {redirect_to}$request_uri;\n" + else: + if exposure_include: + # Seeded by ensure_snippet() at create time (Fix 3, ref #793) — only emitted + # when seeding succeeded, so nginx never references a missing include. + conf += f" include /etc/nginx/snippets/exposure/{domain}.conf;\n" + conf += f" proxy_pass {backend};\n" + conf += " proxy_http_version 1.1;\n" + conf += " proxy_set_header Host $host;\n" + conf += " proxy_set_header X-Real-IP $remote_addr;\n" + conf += " proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n" + conf += " proxy_set_header X-Forwarded-Proto $scheme;\n" + if websocket: + conf += " proxy_set_header Upgrade $http_upgrade;\n" + conf += ' proxy_set_header Connection "upgrade";\n' + conf += " proxy_connect_timeout 60s;\n" + conf += " proxy_send_timeout 60s;\n" + conf += " proxy_read_timeout 60s;\n" + conf += " }\n" + conf += f" access_log /var/log/nginx/{domain}_access.log;\n" + conf += f" error_log /var/log/nginx/{domain}_error.log;\n" + conf += "}\n" return conf @@ -500,19 +505,27 @@ async def create_vhost(vhost: VHostCreate): if conf_file.exists(): raise HTTPException(400, "VHost already exists") - # Seed the exposure snippet FIRST; only include it if seeding succeeded — - # nginx must never reload with an include pointing at a file that may not - # exist (ref #793 Fix 3). A failed seed leaves the vhost ungated but loadable. - seeded = ensure_snippet(vhost.domain) - if not seeded: - import logging - logging.getLogger(__name__).warning( - "exposure snippet seed failed for %s; vhost created ungated", vhost.domain) + redirect_to = (vhost.redirect_to or "").strip() + if not redirect_to and not vhost.backend: + raise HTTPException(400, "backend required unless redirect_to is set") + + # A pure redirect vhost has no proxy/gate → no snippet to seed. Otherwise seed + # the exposure snippet FIRST; only include it if seeding succeeded — nginx must + # never reload with an include pointing at a file that may not exist (ref #793 + # Fix 3). A failed seed leaves the vhost ungated but loadable. + seeded = False + if not redirect_to: + seeded = ensure_snippet(vhost.domain) + if not seeded: + import logging + logging.getLogger(__name__).warning( + "exposure snippet seed failed for %s; vhost created ungated", vhost.domain) # Generate config conf_content = generate_vhost_config( vhost.domain, vhost.backend, vhost.tls_mode, - vhost.websocket, vhost.ssl_redirect, exposure_include=seeded + vhost.websocket, vhost.ssl_redirect, exposure_include=seeded, + redirect_to=redirect_to ) conf_file.write_text(conf_content) diff --git a/packages/secubox-vhost/debian/changelog b/packages/secubox-vhost/debian/changelog index c505563d..a3c28068 100644 --- a/packages/secubox-vhost/debian/changelog +++ b/packages/secubox-vhost/debian/changelog @@ -1,56 +1,9 @@ -secubox-vhost (1.2.0-1~bookworm1) bookworm; urgency=medium +secubox-vhost (1.3.0-1~bookworm1) bookworm; urgency=medium - * feat(#793): read-only exposure badge on /vhosts + webui; seed-on-create and include-wiring into generated vhosts (create + update), so the per-vhost reach gate is emitted safely. + * feat: redirect vhosts — VHostCreate.redirect_to emits a pure `return 301` + vhost (no backend/gate), e.g. www.example.com → https://example.com. + generate_vhost_config gains redirect_to; create_vhost requires a backend + only when redirect_to is unset. - -- Gerald Kerma Sat, 04 Jul 2026 07:41:45 +0200 + -- Gerald Kerma Sat, 04 Jul 2026 09:45:52 +0200 -secubox-vhost (1.1.1-1~bookworm1) bookworm; urgency=medium - - * Fix vhost list: use the real public FQDN (nginx server_name, HAProxy route - map) instead of the config filename stem, set full https:// URLs + correct - ssl (HAProxy terminates TLS), and append HAProxy public routes that have no - nginx config. The list is now the COMPLETE set of reachable vhosts with - working clickable links (was showing short names like "arm"/"lyrion" with - url=None -> broken links). - - -- Gerald KERMA Wed, 24 Jun 2026 14:00:00 +0000 - -secubox-vhost (1.1.0-1~bookworm1) bookworm; urgency=medium - - * Add three-fold architecture (components, status, access) - * Add vhostctl commands: components, access (JSON output) - * Add API endpoint: /components - * Update version to 1.1.0 - - -- Gerald KERMA Sat, 22 Mar 2026 10:45:00 +0100 - -secubox-vhost (1.0.4-1~bookworm1) bookworm; urgency=medium - - * Add dynamic menu system with menu.d JSON definitions - * Menu items auto-registered when package installed - - -- Gerald KERMA Sat, 21 Mar 2026 16:36:21 +0100 - -secubox-vhost (1.0.3-1~bookworm1) bookworm; urgency=medium - - * Add UMask=0000 for world-accessible sockets - * Add www-data to secubox group for nginx access - * Make dashboard endpoints public (no auth for read-only) - * Fix security endpoint error handling - - -- Gerald KERMA Sat, 21 Mar 2026 08:12:37 +0100 - -secubox-vhost (1.0.2-1~bookworm1) bookworm; urgency=medium - - * Fix RuntimeDirectory conflict between services - * Only secubox-core manages /run/secubox directory - * Fix secubox-core.service install path (use /usr/lib/systemd/system) - - -- Gerald KERMA Sat, 21 Mar 2026 08:06:00 +0100 - -secubox-vhost (1.0.1-1~bookworm1) bookworm; urgency=medium - - * Initial release — migration depuis SecuBox OpenWrt. - * Port de luci-app-vhost-manager : backend FastAPI + frontend conservé. - - -- Gerald KERMA Sat, 21 Mar 2026 07:36:46 +0100 diff --git a/packages/secubox-vhost/tests/test_vhost_exposure_wiring.py b/packages/secubox-vhost/tests/test_vhost_exposure_wiring.py index d7f86617..f7bfc87d 100644 --- a/packages/secubox-vhost/tests/test_vhost_exposure_wiring.py +++ b/packages/secubox-vhost/tests/test_vhost_exposure_wiring.py @@ -105,3 +105,34 @@ def test_update_vhost_preserves_exposure_include_on_regenerated_config(tmp_path, conf = (tmp_path / "available" / "gated.example.conf").read_text() assert "include /etc/nginx/snippets/exposure/gated.example.conf;" in conf assert "proxy_pass http://127.0.0.1:9101;" in conf + + +# ── redirect vhost support (ref: www.ganimed.fr → ganimed.fr) ───────────────── + +def test_generate_redirect_vhost_emits_301_no_proxy(): + conf = m.generate_vhost_config( + "www.example.com", "", "off", False, True, redirect_to="https://example.com") + assert "return 301 https://example.com$request_uri;" in conf + assert "proxy_pass" not in conf + # a pure redirect vhost is never exposure-gated + assert "include /etc/nginx/snippets/exposure/" not in conf + + +def test_generate_proxy_vhost_unaffected_when_no_redirect(): + conf = m.generate_vhost_config( + "app.example.com", "http://127.0.0.1:9000", "off", False, True) + assert "proxy_pass http://127.0.0.1:9000;" in conf + assert "return 301" not in conf + + +def test_redirect_vhost_needs_no_backend_but_proxy_does(monkeypatch, tmp_path): + monkeypatch.setattr(m, "NGINX_VHOST_DIR", tmp_path / "va") + monkeypatch.setattr(m, "NGINX_ENABLED_DIR", tmp_path / "ve") + import asyncio + from fastapi import HTTPException + # proxy vhost with no backend → 400 + try: + asyncio.run(m.create_vhost(m.VHostCreate(domain="a.example"))) + assert False, "expected HTTPException" + except HTTPException as e: + assert e.status_code == 400