diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index aafe433..9d00340 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -38,9 +38,10 @@ import ( // Server API 服务器 type Server struct { - mgr *core.Manager - taskMgr *taskmgr.Manager - router *gin.Engine + mgr *core.Manager + taskMgr *taskmgr.Manager + router *gin.Engine + gatewayMu sync.Mutex } func NewServer(mgr *core.Manager) *Server { return &Server{mgr: mgr, taskMgr: taskmgr.NewManager()} } @@ -887,6 +888,12 @@ func (s *Server) createTunnel(c *gin.Context) { t.State = "running" } store.DB.Save(&t) + if t.State == "running" { + if err := s.syncAuthGatewayForTunnel(&t); err != nil { + c.Header("X-Opsx-Gateway-Warning", "route-sync-failed") + log.Printf("专线 %d 已启动但认证入口同步失败: %v", t.ID, err) + } + } } c.JSON(200, t) } @@ -1007,6 +1014,12 @@ func (s *Server) updateTunnel(c *gin.Context) { } } *t = candidate + if wasRunning { + if err := s.syncAuthGatewayForTunnel(t); err != nil { + c.Header("X-Opsx-Gateway-Warning", "route-sync-failed") + log.Printf("专线 %d 已更新但认证入口同步失败: %v", t.ID, err) + } + } store.Audit(c.GetString("username"), "update_tunnel", t.Name, "") c.JSON(200, t) } @@ -1027,6 +1040,10 @@ func (s *Server) deleteTunnel(c *gin.Context) { } s.mgr.RemoveStatus(t.ID) store.DB.Delete(t) + if err := s.syncAuthGatewayForTunnel(t); err != nil { + c.Header("X-Opsx-Gateway-Warning", "route-sync-failed") + log.Printf("专线 %d 已删除但认证入口同步失败: %v", t.ID, err) + } store.Audit(c.GetString("username"), "delete_tunnel", t.Name, "") c.JSON(200, gin.H{"ok": true}) } @@ -1048,6 +1065,10 @@ func (s *Server) startTunnel(c *gin.Context) { return } if t.State == "running" { + if err := s.syncAuthGatewayForTunnel(t); err != nil { + c.JSON(200, gin.H{"ok": true, "state": "running", "warning": "专线运行中,但公网认证入口同步失败:" + err.Error()}) + return + } c.JSON(200, gin.H{"ok": true, "state": "running", "message": "专线已在运行"}) return } @@ -1064,6 +1085,10 @@ func (s *Server) startTunnel(c *gin.Context) { store.DB.Save(t) s.mgr.RemoveStatus(t.ID) // the monitor's previous snapshot can lag behind this transition store.Audit(c.GetString("username"), "start_tunnel", t.Name, "") + if err := s.syncAuthGatewayForTunnel(t); err != nil { + c.JSON(200, gin.H{"ok": true, "state": "running", "warning": "专线已启动,但公网认证入口同步失败:" + err.Error()}) + return + } c.JSON(200, gin.H{"ok": true, "state": "running"}) } @@ -1087,6 +1112,10 @@ func (s *Server) stopTunnel(c *gin.Context) { store.DB.Save(t) s.mgr.RemoveStatus(t.ID) store.Audit(c.GetString("username"), "stop_tunnel", t.Name, "") + if err := s.syncAuthGatewayForTunnel(t); err != nil { + c.JSON(200, gin.H{"ok": true, "state": "stopped", "warning": "专线已停止,但公网认证入口同步失败:" + err.Error()}) + return + } c.JSON(200, gin.H{"ok": true, "state": "stopped"}) } diff --git a/backend/internal/api/batch.go b/backend/internal/api/batch.go index b7d49e6..cdcf9df 100644 --- a/backend/internal/api/batch.go +++ b/backend/internal/api/batch.go @@ -121,6 +121,9 @@ func (s *Server) executeBatchAction(action taskmgr.TaskAction, tunnelID uint, us t.LastError = "" store.DB.Save(&t) store.Audit(username, "batch_start", t.Name, "") + if err := s.syncAuthGatewayForTunnel(&t); err != nil { + return fmt.Errorf("专线已启动,但公网认证入口同步失败: %w", err) + } case taskmgr.ActionStop: p, err := s.mgr.Get(t.Protocol) @@ -136,6 +139,9 @@ func (s *Server) executeBatchAction(action taskmgr.TaskAction, tunnelID uint, us t.State = "stopped" store.DB.Save(&t) store.Audit(username, "batch_stop", t.Name, "") + if err := s.syncAuthGatewayForTunnel(&t); err != nil { + return fmt.Errorf("专线已停止,但公网认证入口同步失败: %w", err) + } case taskmgr.ActionDelete: if p, err := s.mgr.Get(t.Protocol); err == nil { @@ -147,6 +153,9 @@ func (s *Server) executeBatchAction(action taskmgr.TaskAction, tunnelID uint, us s.mgr.RemoveStatus(t.ID) store.DB.Delete(&t) store.Audit(username, "batch_delete", t.Name, "") + if err := s.syncAuthGatewayForTunnel(&t); err != nil { + return fmt.Errorf("专线已删除,但公网认证入口同步失败: %w", err) + } } return nil @@ -185,11 +194,11 @@ func parseTunnelIDs(c *gin.Context) ([]uint, error) { // batchTrafficPolicy 批量设置流量策略 type batchPolicyRequest struct { - TunnelIDs []uint `json:"tunnel_ids" binding:"required,min=1"` - RateLimitMbps int `json:"rate_limit_mbps"` - QuotaBytes int64 `json:"quota_bytes"` - ActionOnExceed string `json:"action_on_exceed"` - ResetCycle string `json:"reset_cycle"` + TunnelIDs []uint `json:"tunnel_ids" binding:"required,min=1"` + RateLimitMbps int `json:"rate_limit_mbps"` + QuotaBytes int64 `json:"quota_bytes"` + ActionOnExceed string `json:"action_on_exceed"` + ResetCycle string `json:"reset_cycle"` } func (s *Server) batchSetPolicy(c *gin.Context) { diff --git a/backend/internal/api/identity_gateway.go b/backend/internal/api/identity_gateway.go index 3ad68eb..ac60a8d 100644 --- a/backend/internal/api/identity_gateway.go +++ b/backend/internal/api/identity_gateway.go @@ -46,6 +46,8 @@ func (*gatewayConflict) Error() string { // 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 { + s.gatewayMu.Lock() + defer s.gatewayMu.Unlock() status := tlsservice.Status() if !status.Valid || status.Mode != "nginx" || status.RestartRequired || nginxgateway.Stage() != "https" { return nil @@ -57,7 +59,16 @@ func (s *Server) RefreshManagedGatewayOnStart() error { return nginxgateway.RefreshAuth(status.Domain, s.router.Routes(), port) } +func (s *Server) syncAuthGatewayForTunnel(t *model.Tunnel) error { + if t.Protocol != "openvpn" || s.router == nil { + return nil + } + return s.RefreshManagedGatewayOnStart() +} + func (s *Server) identityGateway(c *gin.Context) { + s.gatewayMu.Lock() + defer s.gatewayMu.Unlock() status := tlsservice.Status() if !status.Valid || status.Mode != "nginx" || status.RestartRequired || nginxgateway.Stage() != "https" { c.JSON(http.StatusBadRequest, gin.H{"error": "请先完成 Nginx HTTPS 入口配置"}) diff --git a/backend/internal/api/identity_gateway_test.go b/backend/internal/api/identity_gateway_test.go new file mode 100644 index 0000000..5a6f38f --- /dev/null +++ b/backend/internal/api/identity_gateway_test.go @@ -0,0 +1,39 @@ +package api + +import ( + "testing" + + "vpn-platform/backend/internal/model" + "vpn-platform/backend/internal/store" +) + +func TestAuthGatewayPortFollowsRunningOIDCTunnel(t *testing.T) { + oldDB := store.DB + t.Cleanup(func() { store.DB = oldDB }) + if err := store.Init(t.TempDir()); err != nil { + t.Fatal(err) + } + line := model.Tunnel{Name: "oauth-line", Protocol: "openvpn", State: "stopped", Config: `{"auth_mode":"oidc","oidc_base_url":"https://vpn.example.com","oidc_http_port":19090}`} + if err := store.DB.Create(&line).Error; err != nil { + t.Fatal(err) + } + check := func(want int) { + t.Helper() + got, err := authGatewayPort("vpn.example.com") + if err != nil || got != want { + t.Fatalf("gateway port = %d, %v; want %d", got, err, want) + } + } + check(0) + if err := store.DB.Model(&line).Update("state", "running").Error; err != nil { + t.Fatal(err) + } + check(19090) + if port, err := authGatewayPort("other.example.com"); err != nil || port != 0 { + t.Fatalf("wrong domain routed: %d, %v", port, err) + } + if err := store.DB.Model(&line).Update("state", "stopped").Error; err != nil { + t.Fatal(err) + } + check(0) +} diff --git a/docs/specs/SDD-007-built-in-feishu-issuer.md b/docs/specs/SDD-007-built-in-feishu-issuer.md index 4a404e4..9ac7583 100644 --- a/docs/specs/SDD-007-built-in-feishu-issuer.md +++ b/docs/specs/SDD-007-built-in-feishu-issuer.md @@ -73,3 +73,11 @@ 当前 `tenant/v2/tenant/query` 失败时统一显示“请确认应用已开通企业信息读取权限”,掩盖接口路径、调用方式、应用发布状态、权限和网关拒绝等不同故障。页面需要针对企业自建应用给出最小配置步骤:凭据页 App ID/Secret;权限管理查找企业信息读取(`tenant:tenant:readonly`,以控制台实际名称为准)并发布版本;安全设置登记系统显示的精确 HTTPS 飞书回调;网页授权准备 `user_profile` 范围和应用可用范围。自动读取企业标识失败允许手填,但不能把未核实的企业标识视为认证成功。 后端只回显飞书返回的数字错误码与 HTTP 状态,不回显第三方原始 `msg`、访问令牌或凭据;页面保留错误提示以便管理员根据飞书错误码排查,不再把所有错误归因于权限。验收:模拟 `code != 0`、非 2xx、无响应码,区分令牌与企业查询阶段且不泄露上游原始信息;前端引导能看到精确回调、自动查询权限与发布要求;构建和身份模块测试通过。发布先构建和测试,再按现有 `/tmp` 双备份流程部署并检查 HTTPS、401 和 Nginx;恢复为上一版二进制,保留数据库与身份配置。 + +## 9. OpenVPN 浏览器入口随专线状态同步(S3) + +线上 OpenVPN OIDC 专线运行且认证组件监听 `127.0.0.1:19090`,但当专线在管理服务启动后才启动时,受管 Nginx 未同步 `/oauth2/`,浏览器访问 `/oauth2/start` 被兜底 `444` 关闭。现有 `RefreshManagedGatewayOnStart` 仅启动时运行;设置页允许手动同步,容易遗漏。 + +专线启动、停止、自动启动、运行中更新、删除及批量状态变更后,依据现有唯一运行中且公网域名匹配的 OIDC 专线刷新受管站点;串行执行刷新,避免并发 Nginx 写入。仅允许现有严格的回环端口代理,不改变其他路径的 444。刷新失败时专线实际状态仍以数据库为准,向操作方明确报告“专线状态已改变、路由同步失败”,不要谎称 VPN 启动失败或放行宽泛路径。 + +验收:启动后自动出现 `/oauth2/` 转发,停止或删除最后一条专线后移除,重复操作不改变配置;公网无 state 的 `/oauth2/start` 由空响应变为认证组件的 400,Nginx -t 成功;模拟冲突/同步失败时有可见警告。发布先备份二进制、构建和测试,再部署并检查运行中专线不被管理进程重启干扰;恢复为先前二进制并通过设置页手动同步路由,不修改数据库或证书。 diff --git a/frontend/src/views/Tunnels.vue b/frontend/src/views/Tunnels.vue index a768f2c..4052dba 100644 --- a/frontend/src/views/Tunnels.vue +++ b/frontend/src/views/Tunnels.vue @@ -462,7 +462,8 @@ async function changeState(row, action) { if (stateFilter.value && stateFilter.value !== data.state) { list.value = list.value.filter(item => item.id !== row.id) } - ElMessage.success(t(action === 'start' ? 'tunnels.started' : 'tunnels.stopped')) + if (data.warning) ElMessage.warning(data.warning) + else ElMessage.success(t(action === 'start' ? 'tunnels.started' : 'tunnels.stopped')) } catch (e) { ElMessage.error(e.response?.data?.error || t(action === 'start' ? 'tunnels.startFailed' : 'tunnels.stopFailed')) } finally {