Sync OpenVPN OAuth gateway routes on tunnel changes
This commit is contained in:
1 parent
9e26358df0
commit
ff2db503ab
6 files changed
+106
-9
No files matched your search
@@ -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"})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 入口配置"})
|
||||
|
||||
@@ -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` 范围和应用可用范围。自动读取企业标识失败允许手填,但不能把未核实的企业标识视为认证成功。
|
||||
|
||||
后端只回显飞书返回的数字错误码与 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 成功;模拟冲突/同步失败时有可见警告。发布先备份二进制、构建和测试,再部署并检查运行中专线不被管理进程重启干扰;恢复为先前二进制并通过设置页手动同步路由,不修改数据库或证书。
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user