feat(vhost): redirect vhost support (redirect_to → 301)

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.
This commit is contained in:
CyberMind-FR 2026-07-04 09:45:52 +02:00
parent 59225017c3
commit a3e19bba4e
3 changed files with 87 additions and 90 deletions

View File

@ -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)

View File

@ -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 <devel@cybermind.fr> Sat, 04 Jul 2026 07:41:45 +0200
-- Gerald Kerma <devel@cybermind.fr> 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 <devel@cybermind.fr> 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 <devel@cybermind.fr> 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 <devel@cybermind.fr> 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 <devel@cybermind.fr> 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 <devel@cybermind.fr> 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 <devel@cybermind.fr> Sat, 21 Mar 2026 07:36:46 +0100

View File

@ -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