From 6a1bace3e9b4046e10dd6dec82853878e29d5b8d Mon Sep 17 00:00:00 2001 From: opsx Date: Thu, 22 Oct 2026 00:01:12 +0800 Subject: [PATCH] Refresh tunnel actions promptly and clarify Feishu OIDC setup --- README.md | 2 +- backend/internal/api/api.go | 3 + backend/internal/api/tunnel_state_test.go | 79 ++++++++++++++++++++ backend/internal/service/openvpn/oidc.go | 2 +- docs/deploy.md | 2 +- docs/specs/SDD-001-openvpn-oidc.md | 9 ++- frontend/src/components/OpenVPNOIDCGuide.vue | 2 + frontend/src/locales/en-US.json | 8 +- frontend/src/locales/zh-CN.json | 8 +- frontend/src/views/Tunnels.vue | 53 +++++++------ 10 files changed, 136 insertions(+), 32 deletions(-) create mode 100644 backend/internal/api/tunnel_state_test.go diff --git a/README.md b/README.md index 9763b9e..c5d7f52 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交 在「创建专线 → OpenVPN → 认证方式」选择「第三方 OAuth2 / OIDC(飞书登录)」。引导页会展示飞书应用准备、OIDC 信息、公网回调与客户端试连四步。该模式由 [openvpn-auth-oauth2](https://github.com/jkroepke/openvpn-auth-oauth2) 接管 OpenVPN 的 WebAuth 认证;**不会将飞书登录转交给已有的 LDAP 密码校验,也不会将现有 LDAP 用户自动关联为飞书用户**。应用的可用范围应在飞书或身份代理侧明确限定。 -1. 在 [飞书开放平台](https://open.feishu.cn/app) 创建企业自建应用,启用网页登录授权,在应用安全设置中登记回调、发布应用并限定可用人员。先核实飞书租户是否提供标准 OIDC discovery (`/.well-known/openid-configuration`) 与 ID Token;若没有,用 Keycloak / Authentik 等身份代理对接飞书,再把代理的 OIDC issuer 及其客户端凭据填入向导。此时飞书应用登记的是**代理给飞书的上游回调**,不是 VPN 回调;OAuth 授权地址不能代替 issuer。 +1. 在 [飞书开放平台](https://open.feishu.cn/app) 创建企业自建应用,启用网页登录授权,在应用安全设置中登记回调、发布应用并限定可用人员。先核实飞书租户是否提供标准 OIDC discovery (`/.well-known/openid-configuration`) 与 ID Token;若没有,用 Keycloak / Authentik 等身份代理对接飞书,再把代理的 OIDC issuer 及其**新建 VPN OIDC 客户端的 ID/Secret**填入向导。飞书 App ID/Secret 只配置在代理的飞书上游,不能直接当成代理签发的 OIDC 客户端凭据。此时飞书应用登记的是**代理给飞书的上游回调**,不是 VPN 回调;OAuth 授权地址不能代替 issuer。OpsX VPN 当前不提供 OIDC discovery、签名密钥及 ID Token 签发能力,管理地址不能充当 issuer。 2. 在 Linux VPN 主机安装 OpenVPN **2.6.2 或更新版本**及 `openvpn-auth-oauth2` 可执行文件(置于 PATH 或 `data/bin/openvpn-auth-oauth2`)。创建时填 HTTPS issuer、Client ID/Secret、独立公网 HTTPS 入口(例如 `https://vpn-login.example.com`)以及本机服务端口(默认 `19090`)。每条 OIDC 隧道使用不同的公网入口和本机端口。 3. 在最终的 OIDC 身份提供方注册引导页显示的 `https://vpn-login.example.com/oauth2/callback`(直连飞书时登记在飞书应用;经代理时登记在代理的 OIDC 客户端)。公网认证站点可以与管理后台部署在**同一台 opsx-vpn 服务器**:按域名将管理站点转发到 `:18080`、将认证站点的**所有路径**转发到本机 `127.0.0.1:19090`(按实际端口调整);仅开放公网 HTTPS 与 OpenVPN 的 UDP/TCP VPN 端口,不暴露本机认证端口或管理 socket。当前实现由 `openvpn-auth-oauth2` 接收回调,平台的 `:18080` 不处理该路径。 4. 启动专线时平台会运行独立的 OpenVPN 和认证服务进程;OIDC 凭据在数据库加密保存,认证服务的 `openvpn-auth-oauth2.yaml` 与管理密码文件在 `data/instances/-openvpn/` 内以 `0600` 保存。未安装二进制、OpenVPN 版本不足或认证服务启动失败都会报错并停止该专线。浏览器登录失败时不回退到 LDAP 或本地密码。 diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 67049f3..aa8bf1a 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -1040,12 +1040,14 @@ func (s *Server) startTunnel(c *gin.Context) { t.State = "error" t.LastError = err.Error() store.DB.Save(t) + s.mgr.RemoveStatus(t.ID) c.JSON(500, gin.H{"error": err.Error()}) return } t.State = "running" t.LastError = "" 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, "") c.JSON(200, gin.H{"ok": true, "state": "running"}) } @@ -1068,6 +1070,7 @@ func (s *Server) stopTunnel(c *gin.Context) { } t.State = "stopped" store.DB.Save(t) + s.mgr.RemoveStatus(t.ID) store.Audit(c.GetString("username"), "stop_tunnel", t.Name, "") c.JSON(200, gin.H{"ok": true, "state": "stopped"}) } diff --git a/backend/internal/api/tunnel_state_test.go b/backend/internal/api/tunnel_state_test.go new file mode 100644 index 0000000..dcec42a --- /dev/null +++ b/backend/internal/api/tunnel_state_test.go @@ -0,0 +1,79 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/gin-gonic/gin" + + "vpn-platform/backend/internal/core" + "vpn-platform/backend/internal/model" + "vpn-platform/backend/internal/store" +) + +type stateTestProtocol struct{} + +func (stateTestProtocol) Name() string { return "state-test" } +func (stateTestProtocol) DisplayName() string { return "state-test" } +func (stateTestProtocol) Validate(cfg map[string]interface{}) (map[string]interface{}, error) { + return cfg, nil +} +func (stateTestProtocol) Create(*model.Tunnel) error { return nil } +func (stateTestProtocol) Start(*model.Tunnel) error { return nil } +func (stateTestProtocol) Stop(*model.Tunnel) error { return nil } +func (stateTestProtocol) Status(*model.Tunnel) (core.Status, error) { return core.Status{}, nil } +func (stateTestProtocol) GenerateClientConfig(*model.Tunnel) (string, error) { return "", nil } +func (stateTestProtocol) Delete(*model.Tunnel) error { return nil } +func (stateTestProtocol) Available() (bool, string) { return true, "" } + +func TestStartStopDropsStaleRuntimeSnapshot(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: "snapshot", Protocol: "state-test", State: "running", Config: "{}"} + if err := store.DB.Create(&line).Error; err != nil { + t.Fatal(err) + } + mgr := core.NewManager() + mgr.Register(stateTestProtocol{}) + s := NewServer(mgr) + for _, tc := range []struct{ operation, oldState, wantState string }{ + {"stop", "running", "stopped"}, + {"start", "stopped", "running"}, + } { + mgr.SetStatus(line.ID, core.Status{State: tc.oldState}) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: strconv.FormatUint(uint64(line.ID), 10)}} + if tc.operation == "stop" { + s.stopTunnel(c) + } else { + s.startTunnel(c) + } + if w.Code != http.StatusOK { + t.Fatalf("%s returned %d: %s", tc.operation, w.Code, w.Body.String()) + } + if _, ok := mgr.GetStatus(line.ID); ok { + t.Fatalf("%s left the old runtime status cached", tc.operation) + } + + listResponse := httptest.NewRecorder() + listContext, _ := gin.CreateTestContext(listResponse) + s.listTunnels(listContext) + var rows []struct { + State string `json:"state"` + Runtime *core.Status `json:"runtime"` + } + if err := json.Unmarshal(listResponse.Body.Bytes(), &rows); err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].State != tc.wantState || rows[0].Runtime != nil { + t.Fatalf("%s list returned stale state: %+v", tc.operation, rows) + } + } +} diff --git a/backend/internal/service/openvpn/oidc.go b/backend/internal/service/openvpn/oidc.go index 535f4b7..37e01f6 100644 --- a/backend/internal/service/openvpn/oidc.go +++ b/backend/internal/service/openvpn/oidc.go @@ -104,7 +104,7 @@ func prepareOIDC(t *model.Tunnel, cfg map[string]interface{}) error { cfg["oidc_client_secret"] = old["oidc_client_secret"] } if service.Str(cfg, "oidc_client_secret") == "" { - return fmt.Errorf("oidc_client_secret 必填;请在飞书应用凭据中复制 Client Secret") + return fmt.Errorf("oidc_client_secret 必填;请从 OIDC 发行者对应的客户端凭据中复制 Client Secret") } for _, key := range []string{"oidc_http_secret", "oidc_management_password"} { cfg[key] = old[key] // internal secrets are never supplied by the API caller diff --git a/docs/deploy.md b/docs/deploy.md index d2e4634..d89e123 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -51,7 +51,7 @@ sudo ss -tlnp | grep ':18080' ## 飞书 / OIDC 登录入口 -`http://121.4.111.208:18080` 是当前管理界面示例,**既不是 OIDC issuer,也不是 OAuth 回调地址**。当前项目在 [OpenVPN OIDC 配置](../backend/internal/service/openvpn/openvpn.go)中要求 `oidc_issuer` 和 `oidc_base_url` 均为 HTTPS。issuer 指向提供标准 OIDC discovery 和 ID Token 的身份提供方(飞书若不支持,需使用 Keycloak / Authentik 等身份代理)。 +`http://121.4.111.208:18080` 是当前管理界面示例,**既不是 OIDC issuer,也不是 OAuth 回调地址**。当前项目在 [OpenVPN OIDC 配置](../backend/internal/service/openvpn/openvpn.go)中要求 `oidc_issuer` 和 `oidc_base_url` 均为 HTTPS。issuer 指向提供标准 OIDC discovery 和 ID Token 的身份提供方(飞书若不支持,需使用 Keycloak / Authentik 等身份代理)。OpsX VPN 当前不签发 OIDC 令牌;经代理接入时,飞书 App ID/Secret 配在代理的飞书上游,向导的 Client ID/Secret 则来自代理中为 VPN 创建的 OIDC 客户端。 认证入口另设可供 VPN 客户端在连接前访问的 HTTPS 地址,例如 `https://vpn-login.example.com`。它**可以与 opsx-vpn 管理后台部署在同一台服务器上**:反向代理依据域名把管理站点送往 `:18080`,把认证站点的**所有路径**送往同机 `127.0.0.1:19090`(按专线配置调整)。两者是同一台服务器上的不同 Web 服务,并非管理后台不允许提供其他页面;当前实现由平台启动和管理认证组件,回调实际由 `openvpn-auth-oauth2` 处理。最终在 OIDC 身份提供方登记 `https://vpn-login.example.com/oauth2/callback`。不要把本地认证端口或 OpenVPN 管理 socket 暴露到公网。详见 [接入说明](../README.md#openvpn-飞书--oidc-浏览器认证)。 diff --git a/docs/specs/SDD-001-openvpn-oidc.md b/docs/specs/SDD-001-openvpn-oidc.md index ec2477c..beedfce 100644 --- a/docs/specs/SDD-001-openvpn-oidc.md +++ b/docs/specs/SDD-001-openvpn-oidc.md @@ -4,7 +4,7 @@ |------|------| | 版本 / 变更级别 | 0.1 / S3(身份认证、部署) | | 设计状态 | Draft | -| 实现状态 | 已实现本地集成;待 Linux + 飞书环境联调 | +| 实现状态 | OpenVPN WebAuth 配置与认证组件进程管理已实现;真实飞书登录仍待 OIDC 提供方、HTTPS 入口和组件部署联调 | | 负责人 / 日期 | 待指定 / 2026-10-20 | | 关联需求 | 创建 OpenVPN 专线时配置飞书 OIDC 浏览器认证 | | 批准状态 | 未批准;用户已明确要求开始开发 | @@ -70,3 +70,10 @@ |------|----------|----------| | 飞书租户是否公开标准 OIDC discovery / ID token | 阻塞真实联调,不阻塞本地实现 | 先从飞书应用或身份代理获取 issuer 并校验 discovery;不兼容时使用 Keycloak/Authentik 代理 | | 是否需要按 LDAP 用户逐一授权 OIDC | 不阻塞第一版 | 首版仅信任身份源应用的可用范围;逐一绑定必须引入稳定 subject 的独立授权方案 | + +### 2026-10-21 接入核查与发布边界 + +- 飞书应用的 App ID/Secret 是**飞书上游**应用凭据。若使用 Keycloak / Authentik,先在代理中配置飞书上游和其专用回调,再在代理中创建供 `openvpn-auth-oauth2` 使用的 OIDC 客户端;向导中的 issuer、Client ID/Secret 全部从代理获取。仅当飞书本身确实发布可用的 OIDC discovery、JWKS 与 ID Token 时,才能直接使用飞书提供的 issuer 和对应的客户端凭据。OpsX VPN 当前不提供签名密钥、OIDC discovery 或 ID Token,`http://<管理主机>:18080` 不能充当 issuer。 +- 对外回调为 `https://<认证入口>/oauth2/callback`,由 `openvpn-auth-oauth2` 处理;代理方案还要把**代理提供给飞书的上游回调**登记在飞书应用,两条回调不能互换。认证入口可与管理后台同机不同域名部署,需要 HTTPS 与完整认证组件路由转发,`127.0.0.1:19090` 不对公网开放。 +- 2026-10-21 服务器检查:OpenVPN 2.6.19 已安装;`openvpn-auth-oauth2`、Nginx/Certbot 和 80/443 监听均未检测到。未安装组件、准备可访问的 issuer、TLS 证书、飞书应用发布与授权之前,不切换现有运行中的 OpenVPN 专线到 OIDC。先用独立测试专线验证允许与拒绝、断线重连,再变更生产线路。发现失败时停止测试专线并保留原密码专线,不将缺失组件视为已完成登录联调。 +- 飞书应用密钥曾出现在交互截图中,管理员应轮换后仅输入后台密钥框;仓库、文档、日志和测试均不得包含实际密钥。 diff --git a/frontend/src/components/OpenVPNOIDCGuide.vue b/frontend/src/components/OpenVPNOIDCGuide.vue index 0eb7714..33c459d 100644 --- a/frontend/src/components/OpenVPNOIDCGuide.vue +++ b/frontend/src/components/OpenVPNOIDCGuide.vue @@ -14,12 +14,14 @@

{{ $t('oidcGuide.scopeTip') }}

+
{{ $t('oidcGuide.issuerTip') }}
+
{{ $t('oidcGuide.clientIdTip') }}
diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 1a1ced2..37929c4 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -241,16 +241,18 @@ "appInstruction1": "Create an enterprise app, obtain its credentials, and enable the web sign-in capability.", "appInstruction2": "Register a redirect URL under the app's security/redirect settings. If using a broker, use the broker's upstream callback here.", "appInstruction3": "Publish the app and restrict its available users to people allowed to access this VPN.", - "scopeTip": "Explicitly restrict who can use the app. Existing LDAP dial-in users are not automatically linked to Feishu identities. If your tenant lacks a standard OIDC discovery endpoint, use a compatible Keycloak / Authentik broker.", + "scopeTip": "Restrict who can use the Feishu app. LDAP dial-in users are not automatically linked to Feishu identities.", + "providerTip": "A Feishu app alone is not an OIDC issuer. Verify that your provider publishes standard discovery and ID tokens; otherwise use an identity broker such as Keycloak or Authentik. OpsX VPN does not currently issue OIDC identity tokens.", "issuer": "OIDC issuer URL", "issuerTip": "Use an HTTPS issuer that provides standard discovery (/.well-known/openid-configuration) and ID tokens, not an authorization page URL.", "clientId": "OIDC Client ID", + "clientIdTip": "Get the credentials from the provider named by the issuer. With a broker, create a VPN client in the broker; the Feishu App ID/Secret belong in its upstream Feishu configuration.", "clientSecret": "OIDC Client Secret", - "copySecret": "Copy from the app; hidden after saving", + "copySecret": "Copy from the OIDC provider; hidden after saving", "keepSecret": "Leave blank to retain current secret", "secretTip": "Encrypted server-side and stored only in the auth service's 0600 configuration file.", "baseUrl": "Public sign-in URL", - "baseTip": "Use a dedicated HTTPS root URL reachable before VPN connection. Give each OIDC tunnel its own endpoint.", + "baseTip": "Use a public HTTPS root URL, not the current HTTP admin address. The auth component handles browser callbacks; its entry site may share the admin server.", "listenPort": "Local auth service port", "portTip": "Listens only on 127.0.0.1; no public access needed. New tunnels increment from 19090; +/- and manual entry skip configured and locally occupied ports.", "portLookupFailed": "Could not find an available port. Retry or enter one manually.", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index b4b01f8..4924411 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -241,16 +241,18 @@ "appInstruction1": "创建企业自建应用,获取应用凭据,并启用登录所需的网页授权能力。", "appInstruction2": "在应用的安全/重定向地址设置中登记回调;若使用身份代理,这里应填代理给飞书的上游回调地址。", "appInstruction3": "发布应用,并在可用范围中只授权允许使用此 VPN 的人员。", - "scopeTip": "在飞书侧明确设置应用可用人员与授权范围。现有 LDAP 拨号用户不会自动关联飞书身份;如飞书未提供兼容的标准 OIDC 发现地址,请先部署 Keycloak / Authentik 身份代理。", + "scopeTip": "在飞书侧限定应用可用人员。现有 LDAP 拨号用户不会自动关联飞书身份。", + "providerTip": "这张飞书应用页面只证明已创建应用,不能作为 OIDC 发行者。先确认飞书是否提供标准发现文档和 ID Token;否则需要 Keycloak / Authentik 等身份代理。当前 opsx-vpn 不签发 OIDC 身份令牌。", "issuer": "OIDC 发行者地址", "issuerTip": "填写能提供标准 OIDC discovery(/.well-known/openid-configuration)和 ID Token 的 HTTPS 地址;不要填写飞书授权页面地址。", "clientId": "OIDC Client ID", + "clientIdTip": "从上方 issuer 对应的 OIDC 提供方获取:使用身份代理时填代理中创建的 VPN 客户端凭据,飞书 App ID/Secret 只填在代理的飞书上游配置中。", "clientSecret": "OIDC Client Secret", - "copySecret": "从应用凭据复制,保存后不回显", + "copySecret": "从 OIDC 提供方复制,保存后不回显", "keepSecret": "留空保持原密钥", "secretTip": "仅保存在服务端加密存储与 0600 的认证服务配置文件内。", "baseUrl": "认证入口公网地址", - "baseTip": "填写独立 HTTPS 域名根地址;客户端未连上 VPN 时也必须能访问。每条 OIDC 专线使用独立入口。", + "baseTip": "填写可公网访问的 HTTPS 域名根地址;不能用当前 HTTP 管理地址。浏览器回调由认证组件处理,入口站点可与管理后台同机部署。", "listenPort": "本机认证服务端口", "portTip": "仅监听本机 127.0.0.1,无需公网开放。新建专线自动从 19090 起递增;加减或手动输入会跳过已配置和本机正在监听的端口。", "portLookupFailed": "无法获取可用端口,请稍后重试或手动选择", diff --git a/frontend/src/views/Tunnels.vue b/frontend/src/views/Tunnels.vue index 6d9107e..a768f2c 100644 --- a/frontend/src/views/Tunnels.vue +++ b/frontend/src/views/Tunnels.vue @@ -75,24 +75,24 @@ - +