Sync OpenVPN OAuth gateway routes on tunnel changes

This commit is contained in:
opsx committed 2026-10-22 19:50:24 +08:00
1 parent 9e26358df0
commit ff2db503ab
6 files changed
+106 -9

No files matched your search

+32 -3
View File
@@ -38,9 +38,10 @@ import (
// Server API 服务器 // Server API 服务器
type Server struct { type Server struct {
mgr *core.Manager mgr *core.Manager
taskMgr *taskmgr.Manager taskMgr *taskmgr.Manager
router *gin.Engine router *gin.Engine
gatewayMu sync.Mutex
} }
func NewServer(mgr *core.Manager) *Server { return &Server{mgr: mgr, taskMgr: taskmgr.NewManager()} } 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" t.State = "running"
} }
store.DB.Save(&t) 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) c.JSON(200, t)
} }
@@ -1007,6 +1014,12 @@ func (s *Server) updateTunnel(c *gin.Context) {
} }
} }
*t = candidate *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, "") store.Audit(c.GetString("username"), "update_tunnel", t.Name, "")
c.JSON(200, t) c.JSON(200, t)
} }
@@ -1027,6 +1040,10 @@ func (s *Server) deleteTunnel(c *gin.Context) {
} }
s.mgr.RemoveStatus(t.ID) s.mgr.RemoveStatus(t.ID)
store.DB.Delete(t) 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, "") store.Audit(c.GetString("username"), "delete_tunnel", t.Name, "")
c.JSON(200, gin.H{"ok": true}) c.JSON(200, gin.H{"ok": true})
} }
@@ -1048,6 +1065,10 @@ func (s *Server) startTunnel(c *gin.Context) {
return return
} }
if t.State == "running" { 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": "专线已在运行"}) c.JSON(200, gin.H{"ok": true, "state": "running", "message": "专线已在运行"})
return return
} }
@@ -1064,6 +1085,10 @@ func (s *Server) startTunnel(c *gin.Context) {
store.DB.Save(t) store.DB.Save(t)
s.mgr.RemoveStatus(t.ID) // the monitor's previous snapshot can lag behind this transition s.mgr.RemoveStatus(t.ID) // the monitor's previous snapshot can lag behind this transition
store.Audit(c.GetString("username"), "start_tunnel", t.Name, "") 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"}) c.JSON(200, gin.H{"ok": true, "state": "running"})
} }
@@ -1087,6 +1112,10 @@ func (s *Server) stopTunnel(c *gin.Context) {
store.DB.Save(t) store.DB.Save(t)
s.mgr.RemoveStatus(t.ID) s.mgr.RemoveStatus(t.ID)
store.Audit(c.GetString("username"), "stop_tunnel", t.Name, "") 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"}) c.JSON(200, gin.H{"ok": true, "state": "stopped"})
} }
+14 -5
View File
@@ -121,6 +121,9 @@ func (s *Server) executeBatchAction(action taskmgr.TaskAction, tunnelID uint, us
t.LastError = "" t.LastError = ""
store.DB.Save(&t) store.DB.Save(&t)
store.Audit(username, "batch_start", t.Name, "") store.Audit(username, "batch_start", t.Name, "")
if err := s.syncAuthGatewayForTunnel(&t); err != nil {
return fmt.Errorf("专线已启动,但公网认证入口同步失败: %w", err)
}
case taskmgr.ActionStop: case taskmgr.ActionStop:
p, err := s.mgr.Get(t.Protocol) p, err := s.mgr.Get(t.Protocol)
@@ -136,6 +139,9 @@ func (s *Server) executeBatchAction(action taskmgr.TaskAction, tunnelID uint, us
t.State = "stopped" t.State = "stopped"
store.DB.Save(&t) store.DB.Save(&t)
store.Audit(username, "batch_stop", t.Name, "") store.Audit(username, "batch_stop", t.Name, "")
if err := s.syncAuthGatewayForTunnel(&t); err != nil {
return fmt.Errorf("专线已停止,但公网认证入口同步失败: %w", err)
}
case taskmgr.ActionDelete: case taskmgr.ActionDelete:
if p, err := s.mgr.Get(t.Protocol); err == nil { 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) s.mgr.RemoveStatus(t.ID)
store.DB.Delete(&t) store.DB.Delete(&t)
store.Audit(username, "batch_delete", t.Name, "") store.Audit(username, "batch_delete", t.Name, "")
if err := s.syncAuthGatewayForTunnel(&t); err != nil {
return fmt.Errorf("专线已删除,但公网认证入口同步失败: %w", err)
}
} }
return nil return nil
@@ -185,11 +194,11 @@ func parseTunnelIDs(c *gin.Context) ([]uint, error) {
// batchTrafficPolicy 批量设置流量策略 // batchTrafficPolicy 批量设置流量策略
type batchPolicyRequest struct { type batchPolicyRequest struct {
TunnelIDs []uint `json:"tunnel_ids" binding:"required,min=1"` TunnelIDs []uint `json:"tunnel_ids" binding:"required,min=1"`
RateLimitMbps int `json:"rate_limit_mbps"` RateLimitMbps int `json:"rate_limit_mbps"`
QuotaBytes int64 `json:"quota_bytes"` QuotaBytes int64 `json:"quota_bytes"`
ActionOnExceed string `json:"action_on_exceed"` ActionOnExceed string `json:"action_on_exceed"`
ResetCycle string `json:"reset_cycle"` ResetCycle string `json:"reset_cycle"`
} }
func (s *Server) batchSetPolicy(c *gin.Context) { func (s *Server) batchSetPolicy(c *gin.Context) {
+11
View File
@@ -46,6 +46,8 @@ func (*gatewayConflict) Error() string {
// with the binary after upgrades. It only touches this application's managed // with the binary after upgrades. It only touches this application's managed
// Nginx site; RefreshAuth validates it and rolls back on failed reloads. // Nginx site; RefreshAuth validates it and rolls back on failed reloads.
func (s *Server) RefreshManagedGatewayOnStart() error { func (s *Server) RefreshManagedGatewayOnStart() error {
s.gatewayMu.Lock()
defer s.gatewayMu.Unlock()
status := tlsservice.Status() status := tlsservice.Status()
if !status.Valid || status.Mode != "nginx" || status.RestartRequired || nginxgateway.Stage() != "https" { if !status.Valid || status.Mode != "nginx" || status.RestartRequired || nginxgateway.Stage() != "https" {
return nil return nil
@@ -57,7 +59,16 @@ func (s *Server) RefreshManagedGatewayOnStart() error {
return nginxgateway.RefreshAuth(status.Domain, s.router.Routes(), port) 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) { func (s *Server) identityGateway(c *gin.Context) {
s.gatewayMu.Lock()
defer s.gatewayMu.Unlock()
status := tlsservice.Status() status := tlsservice.Status()
if !status.Valid || status.Mode != "nginx" || status.RestartRequired || nginxgateway.Stage() != "https" { if !status.Valid || status.Mode != "nginx" || status.RestartRequired || nginxgateway.Stage() != "https" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请先完成 Nginx HTTPS 入口配置"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请先完成 Nginx HTTPS 入口配置"})
@@ -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)
}
@@ -73,3 +73,11 @@
当前 `tenant/v2/tenant/query` 失败时统一显示“请确认应用已开通企业信息读取权限”,掩盖接口路径、调用方式、应用发布状态、权限和网关拒绝等不同故障。页面需要针对企业自建应用给出最小配置步骤:凭据页 App ID/Secret;权限管理查找企业信息读取(`tenant:tenant:readonly`,以控制台实际名称为准)并发布版本;安全设置登记系统显示的精确 HTTPS 飞书回调;网页授权准备 `user_profile` 范围和应用可用范围。自动读取企业标识失败允许手填,但不能把未核实的企业标识视为认证成功。 当前 `tenant/v2/tenant/query` 失败时统一显示“请确认应用已开通企业信息读取权限”,掩盖接口路径、调用方式、应用发布状态、权限和网关拒绝等不同故障。页面需要针对企业自建应用给出最小配置步骤:凭据页 App ID/Secret;权限管理查找企业信息读取(`tenant:tenant:readonly`,以控制台实际名称为准)并发布版本;安全设置登记系统显示的精确 HTTPS 飞书回调;网页授权准备 `user_profile` 范围和应用可用范围。自动读取企业标识失败允许手填,但不能把未核实的企业标识视为认证成功。
后端只回显飞书返回的数字错误码与 HTTP 状态,不回显第三方原始 `msg`、访问令牌或凭据;页面保留错误提示以便管理员根据飞书错误码排查,不再把所有错误归因于权限。验收:模拟 `code != 0`、非 2xx、无响应码,区分令牌与企业查询阶段且不泄露上游原始信息;前端引导能看到精确回调、自动查询权限与发布要求;构建和身份模块测试通过。发布先构建和测试,再按现有 `/tmp` 双备份流程部署并检查 HTTPS、401 和 Nginx;恢复为上一版二进制,保留数据库与身份配置。 后端只回显飞书返回的数字错误码与 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 成功;模拟冲突/同步失败时有可见警告。发布先备份二进制、构建和测试,再部署并检查运行中专线不被管理进程重启干扰;恢复为先前二进制并通过设置页手动同步路由,不修改数据库或证书。
+2 -1
View File
@@ -462,7 +462,8 @@ async function changeState(row, action) {
if (stateFilter.value && stateFilter.value !== data.state) { if (stateFilter.value && stateFilter.value !== data.state) {
list.value = list.value.filter(item => item.id !== row.id) 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) { } catch (e) {
ElMessage.error(e.response?.data?.error || t(action === 'start' ? 'tunnels.startFailed' : 'tunnels.stopFailed')) ElMessage.error(e.response?.data?.error || t(action === 'start' ? 'tunnels.startFailed' : 'tunnels.stopFailed'))
} finally { } finally {