mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 13:59:40 +00:00
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:
parent
59225017c3
commit
a3e19bba4e
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user