From e15768b25b37d399e7516b7871ca449c8dd07bb9 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 8 Jul 2026 18:54:27 +0700 Subject: [PATCH] feat(firewall): implement public HTTPS endpoints for firewall scripts and enhance URL handling Added public HTTPS endpoints for firewall installation and enrollment scripts, allowing access without API keys. Updated the URL handling in the firewall code to ensure all suggested control plane URLs are served over HTTPS. Enhanced documentation to reflect the new public endpoints and their usage. Updated tests to verify the correct behavior of the new URL handling logic. --- apps/web/src/routes/_auth/firewall.tsx | 14 +++++++++++-- .../docker-compose.production.example.yaml | 8 +++++++ deploy/compose/docker-compose.yaml | 8 +++++++ deploy/compose/stack.microvps-full.yaml | 8 +++++++ deploy/docker/evobgp-web/nginx.conf | 2 +- docs/firewall.md | 2 ++ docs/quickstart.md | 1 + internal/httpapi/routes_firewall.go | 21 ++++++++----------- internal/httpapi/routes_firewall_test.go | 5 ++++- 9 files changed, 53 insertions(+), 16 deletions(-) diff --git a/apps/web/src/routes/_auth/firewall.tsx b/apps/web/src/routes/_auth/firewall.tsx index b035e79..5ce3c99 100644 --- a/apps/web/src/routes/_auth/firewall.tsx +++ b/apps/web/src/routes/_auth/firewall.tsx @@ -34,6 +34,16 @@ import { } from '@/queries/firewall' import type { BgpCommunity, FirewallClient } from '@/types/api' +function httpsOrigin(origin: string): string { + try { + const u = new URL(origin) + u.protocol = 'https:' + return u.origin + } catch { + return origin.replace(/^http:/i, 'https:') + } +} + export const Route = createFileRoute('/_auth/firewall')({ component: FirewallPage, }) @@ -51,13 +61,13 @@ function FirewallPage() { const [clientName, setClientName] = useState('web-01') const [cpUrl, setCpUrl] = useState(() => - typeof window !== 'undefined' ? window.location.origin : 'https://api.example.com', + typeof window !== 'undefined' ? httpsOrigin(window.location.origin) : 'https://api.example.com', ) const [seed, setSeed] = useState('') useEffect(() => { if (installCtx?.suggested_cp_url) { - setCpUrl(installCtx.suggested_cp_url) + setCpUrl(httpsOrigin(installCtx.suggested_cp_url)) } if (installCtx?.bundle_seed) { setSeed(installCtx.bundle_seed) diff --git a/deploy/compose/docker-compose.production.example.yaml b/deploy/compose/docker-compose.production.example.yaml index 5a68009..2745677 100644 --- a/deploy/compose/docker-compose.production.example.yaml +++ b/deploy/compose/docker-compose.production.example.yaml @@ -159,10 +159,18 @@ services: condition: service_started labels: - traefik.enable=true + # Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов). + - traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`)) + - traefik.http.routers.evobgp-firewall-public.entrypoints=websecure + - traefik.http.routers.evobgp-firewall-public.tls=true + - traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt + - traefik.http.routers.evobgp-firewall-public.priority=100 + - traefik.http.routers.evobgp-firewall-public.service=evobgp-web - traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`) - traefik.http.routers.evobgp-web.entrypoints=websecure - traefik.http.routers.evobgp-web.tls=true - traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt + - traefik.http.routers.evobgp-web.priority=10 - traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker - traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST} - traefik.http.services.evobgp-web.loadbalancer.server.port=80 diff --git a/deploy/compose/docker-compose.yaml b/deploy/compose/docker-compose.yaml index 048abba..a3ccd7e 100644 --- a/deploy/compose/docker-compose.yaml +++ b/deploy/compose/docker-compose.yaml @@ -247,10 +247,18 @@ services: - evobgp-all labels: - traefik.enable=true + # Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов). + - traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`)) + - traefik.http.routers.evobgp-firewall-public.entrypoints=websecure + - traefik.http.routers.evobgp-firewall-public.tls=true + - traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt + - traefik.http.routers.evobgp-firewall-public.priority=100 + - traefik.http.routers.evobgp-firewall-public.service=evobgp-web - traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`) - traefik.http.routers.evobgp-web.entrypoints=websecure - traefik.http.routers.evobgp-web.tls=true - traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt + - traefik.http.routers.evobgp-web.priority=10 - traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker - traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST} - traefik.http.services.evobgp-web.loadbalancer.server.port=80 diff --git a/deploy/compose/stack.microvps-full.yaml b/deploy/compose/stack.microvps-full.yaml index 0ace3cb..31fb19a 100644 --- a/deploy/compose/stack.microvps-full.yaml +++ b/deploy/compose/stack.microvps-full.yaml @@ -162,10 +162,18 @@ services: condition: service_started labels: - traefik.enable=true + # Публичные firewall-эндпоинты — без WEBUI_IP_WHITELIST (установка с произвольных серверов). + - traefik.http.routers.evobgp-firewall-public.rule=Host(`${WEBUI_DOMAIN}`) && (Path(`/v1/firewall/install.sh`) || Path(`/v1/firewall/sync-script`) || PathPrefix(`/v1/firewall/enroll`)) + - traefik.http.routers.evobgp-firewall-public.entrypoints=websecure + - traefik.http.routers.evobgp-firewall-public.tls=true + - traefik.http.routers.evobgp-firewall-public.tls.certresolver=letsencrypt + - traefik.http.routers.evobgp-firewall-public.priority=100 + - traefik.http.routers.evobgp-firewall-public.service=evobgp-web - traefik.http.routers.evobgp-web.rule=Host(`${WEBUI_DOMAIN}`) - traefik.http.routers.evobgp-web.entrypoints=websecure - traefik.http.routers.evobgp-web.tls=true - traefik.http.routers.evobgp-web.tls.certresolver=letsencrypt + - traefik.http.routers.evobgp-web.priority=10 - traefik.http.routers.evobgp-web.middlewares=webui-ipwhitelist@docker - traefik.http.middlewares.webui-ipwhitelist.ipallowlist.sourcerange=${WEBUI_IP_WHITELIST} - traefik.http.services.evobgp-web.loadbalancer.server.port=80 diff --git a/deploy/docker/evobgp-web/nginx.conf b/deploy/docker/evobgp-web/nginx.conf index 4f88710..7b25710 100644 --- a/deploy/docker/evobgp-web/nginx.conf +++ b/deploy/docker/evobgp-web/nginx.conf @@ -17,7 +17,7 @@ server { 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; + proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto; } location = /metrics { diff --git a/docs/firewall.md b/docs/firewall.md index 0d6a2fd..3ee2121 100644 --- a/docs/firewall.md +++ b/docs/firewall.md @@ -21,6 +21,8 @@ ## Установка на сервер +Публичные URL (без API-ключа, вне `WEBUI_IP_WHITELIST` Traefik): `GET /v1/firewall/install.sh`, `GET /v1/firewall/sync-script`, `POST /v1/firewall/enroll`. Всегда **HTTPS**. + ```bash curl -fsSL https:///v1/firewall/install.sh | \ EVOBGP_CP_URL=https:// \ diff --git a/docs/quickstart.md b/docs/quickstart.md index 3476347..9544bf5 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -153,6 +153,7 @@ docker compose --env-file .env --env-file .env.web-sec --profile microvps-full u - `http://` должен редиректить на `https://`; - с IP из `WEBUI_IP_WHITELIST` UI доступен по HTTPS; - с неразрешенного IP Traefik вернет `403`. +- исключение: `GET /v1/firewall/install.sh`, `GET /v1/firewall/sync-script`, `POST /v1/firewall/enroll` — публичные, без whitelist (см. [firewall.md](firewall.md)). Health API: `http://:8080/v1/health`. diff --git a/internal/httpapi/routes_firewall.go b/internal/httpapi/routes_firewall.go index 1a82965..51a95a5 100644 --- a/internal/httpapi/routes_firewall.go +++ b/internal/httpapi/routes_firewall.go @@ -50,20 +50,13 @@ func (s *Server) handleFirewallInstallContext(w http.ResponseWriter, r *http.Req writeJSON(w, http.StatusOK, map[string]any{ "bundle_seed": seed, "bundle_seed_configured": seed != "", - "suggested_cp_url": requestBaseURL(r), - "install_sh_url": requestBaseURL(r) + "/v1/firewall/install.sh", + "suggested_cp_url": publicHTTPSBaseURL(r), + "install_sh_url": publicHTTPSBaseURL(r) + "/v1/firewall/install.sh", }) } -func requestBaseURL(r *http.Request) string { - scheme := "https" - if r.TLS == nil { - if xf := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); xf != "" { - scheme = strings.ToLower(strings.Split(xf, ",")[0]) - } else if strings.EqualFold(r.URL.Scheme, "http") { - scheme = "http" - } - } +// publicHTTPSBaseURL is the external HTTPS origin for firewall install/enroll links. +func publicHTTPSBaseURL(r *http.Request) string { host := strings.TrimSpace(r.Host) if xf := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); xf != "" { host = strings.TrimSpace(strings.Split(xf, ",")[0]) @@ -71,7 +64,11 @@ func requestBaseURL(r *http.Request) string { if host == "" { return "" } - return scheme + "://" + host + return "https://" + host +} + +func requestBaseURL(r *http.Request) string { + return publicHTTPSBaseURL(r) } func (s *Server) handleFirewallEnrollPublic(w http.ResponseWriter, r *http.Request) { diff --git a/internal/httpapi/routes_firewall_test.go b/internal/httpapi/routes_firewall_test.go index cc57ede..ae0a640 100644 --- a/internal/httpapi/routes_firewall_test.go +++ b/internal/httpapi/routes_firewall_test.go @@ -186,7 +186,10 @@ func TestFirewallInstallContext(t *testing.T) { if configured, _ := ctx["bundle_seed_configured"].(bool); !configured { t.Fatal("bundle_seed_configured want true") } - if url, _ := ctx["install_sh_url"].(string); !strings.HasSuffix(url, "/v1/firewall/install.sh") { + if url, _ := ctx["suggested_cp_url"].(string); !strings.HasPrefix(url, "https://") { + t.Fatalf("suggested_cp_url=%q want https", url) + } + if url, _ := ctx["install_sh_url"].(string); !strings.HasPrefix(url, "https://") || !strings.HasSuffix(url, "/v1/firewall/install.sh") { t.Fatalf("install_sh_url=%q", url) }