From 0528bc4b9ab15252d62b23db7b298ba5a2b3aea6 Mon Sep 17 00:00:00 2001 From: opsx Date: Thu, 22 Oct 2026 10:49:32 +0800 Subject: [PATCH] Sync managed gateway routes on startup and fix layout initialization --- backend/internal/api/identity_gateway.go | 15 +++++++++++++++ backend/internal/nginxconfig/nginxconfig_test.go | 9 +++++++-- backend/internal/nginxgateway/gateway.go | 3 +++ backend/main.go | 3 +++ docs/specs/SDD-007-built-in-feishu-issuer.md | 6 ++++++ frontend/src/components/ProtocolIcon.vue | 3 +++ frontend/src/views/Layout.vue | 7 +++---- 7 files changed, 40 insertions(+), 6 deletions(-) diff --git a/backend/internal/api/identity_gateway.go b/backend/internal/api/identity_gateway.go index 8d6662c..3ad68eb 100644 --- a/backend/internal/api/identity_gateway.go +++ b/backend/internal/api/identity_gateway.go @@ -42,6 +42,21 @@ func (*gatewayConflict) Error() string { return "当前域名存在多条运行中的 OIDC 专线,请先保留一条" } +// RefreshManagedGatewayOnStart keeps the installed route allowlist in sync +// with the binary after upgrades. It only touches this application's managed +// Nginx site; RefreshAuth validates it and rolls back on failed reloads. +func (s *Server) RefreshManagedGatewayOnStart() error { + status := tlsservice.Status() + if !status.Valid || status.Mode != "nginx" || status.RestartRequired || nginxgateway.Stage() != "https" { + return nil + } + port, err := authGatewayPort(status.Domain) + if err != nil { + return err + } + return nginxgateway.RefreshAuth(status.Domain, s.router.Routes(), port) +} + func (s *Server) identityGateway(c *gin.Context) { status := tlsservice.Status() if !status.Valid || status.Mode != "nginx" || status.RestartRequired || nginxgateway.Stage() != "https" { diff --git a/backend/internal/nginxconfig/nginxconfig_test.go b/backend/internal/nginxconfig/nginxconfig_test.go index 7e5ef98..202d3dc 100644 --- a/backend/internal/nginxconfig/nginxconfig_test.go +++ b/backend/internal/nginxconfig/nginxconfig_test.go @@ -66,11 +66,16 @@ func TestRenderRejectsInjectedDomainAndRoute(t *testing.T) { } func TestRenderIdentityAndSingleAuthPort(t *testing.T) { - _, site, err := RenderWithAuth("vpn.example.com", gin.RoutesInfo{{Method: "GET", Path: "/api/health"}}, 19090) + _, site, err := RenderWithAuth("vpn.example.com", gin.RoutesInfo{ + {Method: "GET", Path: "/api/health"}, + {Method: "PUT", Path: "/api/system/tls/domain"}, + {Method: "PUT", Path: "/api/system/identity"}, + {Method: "POST", Path: "/api/system/identity/gateway"}, + }, 19090) if err != nil { t.Fatal(err) } - for _, route := range []string{"location = /id/.well-known/openid-configuration", "location = /id/feishu/callback", "location = /id/token", "location ^~ /oauth2/", "proxy_pass http://127.0.0.1:19090;", "location / { return 444; }"} { + for _, route := range []string{"location = /id/.well-known/openid-configuration", "location = /id/feishu/callback", "location = /id/token", "location = /api/system/tls/domain", "location = /api/system/identity", "location = /api/system/identity/gateway", "location ^~ /oauth2/", "proxy_pass http://127.0.0.1:19090;", "location / { return 444; }"} { if !strings.Contains(site, route) { t.Errorf("identity route missing: %s", route) } diff --git a/backend/internal/nginxgateway/gateway.go b/backend/internal/nginxgateway/gateway.go index 636d122..b6d6fa5 100644 --- a/backend/internal/nginxgateway/gateway.go +++ b/backend/internal/nginxgateway/gateway.go @@ -79,6 +79,9 @@ func RefreshAuth(domain string, routes gin.RoutesInfo, authPort int) error { if err != nil { return err } + if string(previous) == httpSite+httpsSite { + return nil + } if err := writeSite(httpSite + httpsSite); err != nil { return err } diff --git a/backend/main.go b/backend/main.go index 3961203..3c03981 100644 --- a/backend/main.go +++ b/backend/main.go @@ -115,6 +115,9 @@ func main() { if err := tlsservice.Start(r, dataDir); err != nil { log.Fatalf("HTTPS/ACME 启动失败: %v", err) } + if err := srv.RefreshManagedGatewayOnStart(); err != nil { + log.Printf("Nginx 路由白名单同步失败,仍可通过本机入口检查: %v", err) + } addr := os.Getenv("VPN_LISTEN_ADDR") if addr == "" { diff --git a/docs/specs/SDD-007-built-in-feishu-issuer.md b/docs/specs/SDD-007-built-in-feishu-issuer.md index d4cd0f4..0971c05 100644 --- a/docs/specs/SDD-007-built-in-feishu-issuer.md +++ b/docs/specs/SDD-007-built-in-feishu-issuer.md @@ -46,3 +46,9 @@ 现有专线编辑页仍把外部 OIDC 的 issuer 与 VPN Client Secret 输入框作为主流程,容易让管理员误把飞书 App ID/Secret 填入专线。管理员仅在「设置 → 飞书身份」维护全局飞书 App ID、App Secret 和 tenant_key;专线的内置模式自动引用当前 issuer、独立 VPN Client ID 和公网入口。编辑既有外部 OIDC 专线时保持原始设置,只有明确切换来源才更改字段。外部 OIDC 仍允许手工配置,不把飞书上游凭据放入专线。 验收:专线编辑和新建都能直达身份设置;内置模式不展示飞书密钥或可编辑的 VPN 密钥,提示飞书回调与 VPN 回调的区别;外部模式保留原先的输入能力;服务端对内置标记校验已启用的发行者、Client ID 和当前域名,不因提交旧密钥而回退至外部模式。该迭代不自动切换、启用或重启已有专线;上线前构建前端并跑相关 Go 测试,上线后检查管理入口与禁用的身份入口,失败恢复上一版二进制。 + +## 6. 公网白名单与页面故障修复 + +线上新增 `/api/system/identity` 后托管 Nginx 仍使用旧白名单,浏览器看到 `ERR_EMPTY_RESPONSE`(Nginx 444);本机管理进程可正常返回 401。已有 `/api/system/tls/domain` 路由在无凭据时返回 401,不能把用户同时报告的域名保存失败归结于同一缺失路由。Vue 侧栏在 `route` 创建前注册 watch,导致初始化时引用尚未声明的变量;WireGuard SVG 含截断的路径数据。 + +每次启动且确认当前是有效 Nginx HTTPS 托管站点时,按已注册 Gin 路由重建站点白名单;文件内容相同则不重载,冲突或 `nginx -t` 失败时保留旧站点并记录日志,不阻止本机服务启动。公网只同步已注册 API、固定身份端点与当前单条运行的同域认证组件,未知路径仍 444。验收:新身份管理 API 不再返回空连接,未经认证返回 401;既有域名 PUT 也仍返回 401,授权后的保存需单独实测;设置和编辑页无布局初始化异常;SVG 路径有效;Nginx 测试通过。恢复时使用 `/tmp` 的上一版二进制和原 Nginx 站点快照重新加载,不修改专线或飞书凭据。 diff --git a/frontend/src/components/ProtocolIcon.vue b/frontend/src/components/ProtocolIcon.vue index c6e176d..d1bfd60 100644 --- a/frontend/src/components/ProtocolIcon.vue +++ b/frontend/src/components/ProtocolIcon.vue @@ -75,6 +75,9 @@ const ICON_PATHS = { const pathData = computed(() => { const key = props.protocol.startsWith('custom:') ? 'custom' : props.protocol + // The imported WireGuard path was truncated in the original asset. Keep a + // valid compact tunnel symbol until the full vector is replaced. + if (key === 'wireguard') return 'M12 2 3 6v6c0 5.25 3.84 9.74 9 10 5.16-.26 9-4.75 9-10V6L12 2zm0 2.2 7 2.8v5c0 4.1-2.6 7.5-7 8.7-4.4-1.2-7-4.6-7-8.7V7l7-2.8zM7 11h10v2H7zm2-3h6v2H9zm0 6h6v2H9z' return ICON_PATHS[key] || ICON_PATHS.custom }) diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index 6f0d843..06621c1 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -109,6 +109,9 @@ import { useI18n } from 'vue-i18n' import { DataAnalysis, Connection, Setting, Avatar, ArrowDown, UserFilled, Iphone, Lock, Monitor, Promotion, Document, Link, Expand, Fold, Close, Operation } from '@element-plus/icons-vue' import api, { auth } from '../api' +const router = useRouter() +const route = useRoute() +const { t, locale } = useI18n() const collapsed = ref(localStorage.getItem('vpn_nav_collapsed') === '1') const mobileOpen = ref(false) function toggleCollapsed() { @@ -119,10 +122,6 @@ watch(() => route.fullPath, () => { mobileOpen.value = false }) function onEscape(event) { if (event.key === 'Escape') mobileOpen.value = false } onMounted(() => window.addEventListener('keydown', onEscape)) onUnmounted(() => window.removeEventListener('keydown', onEscape)) -const router = useRouter() -const route = useRoute() -const { t, locale } = useI18n() - const roleLabel = computed(() => ({ admin: t('users.roleAdmin'), operator: t('users.roleOperator'), viewer: t('users.roleViewer') }[auth.role] || auth.role)) const roleTagType = computed(() => auth.role === 'admin' ? 'danger' : auth.role === 'operator' ? 'warning' : 'info')