diff --git a/backend/internal/service/openvpn/oidc.go b/backend/internal/service/openvpn/oidc.go index af5019c..ba63f9e 100644 --- a/backend/internal/service/openvpn/oidc.go +++ b/backend/internal/service/openvpn/oidc.go @@ -60,13 +60,35 @@ func yamlString(s string) string { return string(b) } -func oidcConfig(t *model.Tunnel, cfg map[string]interface{}) string { +func supportsOverrideUsername() bool { + bin, ok := findOpenVPNBin() + if !ok { + return false + } + out, err := exec.Command(bin, "--version").Output() + if err != nil { + return false + } + m := regexp.MustCompile(`OpenVPN\s+(\d+)\.(\d+)\.(\d+)`).FindSubmatch(out) + if len(m) != 4 { + return false + } + major, _ := strconv.Atoi(string(m[1])) + minor, _ := strconv.Atoi(string(m[2])) + return major > 2 || major == 2 && minor >= 7 +} + +func oidcConfig(t *model.Tunnel, cfg map[string]interface{}, overrideUsername bool) string { dir := service.InstanceDir(t) usernameConfig := "" + overrideConfig := "" if service.Str(cfg, "oidc_provider") == "builtin" { usernameConfig = " openvpn-username: \"string(token.claims.sub)\"\n" + if overrideUsername { + overrideConfig = " override-username: true\n" + } } - return fmt.Sprintf("http:\n baseurl: %s\n listen: %s\n secret: %s\n template: %s\noauth2:\n%s issuer: %s\n client:\n id: %s\n secret: %s\nopenvpn:\n addr: %s\n password: %s\n auth-token-user: true\n", + return fmt.Sprintf("http:\n baseurl: %s\n listen: %s\n secret: %s\n template: %s\noauth2:\n%s issuer: %s\n client:\n id: %s\n secret: %s\nopenvpn:\n%s addr: %s\n password: %s\n auth-token-user: true\n", yamlString(service.Str(cfg, "oidc_base_url")), yamlString(fmt.Sprintf("127.0.0.1:%d", int(service.Num(cfg, "oidc_http_port", 19090)))), yamlString(service.Str(cfg, "oidc_http_secret")), @@ -75,6 +97,7 @@ func oidcConfig(t *model.Tunnel, cfg map[string]interface{}) string { yamlString(service.Str(cfg, "oidc_issuer")), yamlString(service.Str(cfg, "oidc_client_id")), yamlString(service.Str(cfg, "oidc_client_secret")), + overrideConfig, yamlString("unix://"+filepath.Join(dir, "management.sock")), yamlString(service.Str(cfg, "oidc_management_password"))) } @@ -153,7 +176,7 @@ func prepareOIDC(t *model.Tunnel, cfg map[string]interface{}) error { if err := os.Chmod(pagePath, 0o600); err != nil { return err } - configPath, err := service.WriteConfig(t, "openvpn-auth-oauth2.yaml", oidcConfig(t, cfg)) + configPath, err := service.WriteConfig(t, "openvpn-auth-oauth2.yaml", oidcConfig(t, cfg, supportsOverrideUsername())) if err != nil { return err } diff --git a/backend/internal/service/openvpn/openvpn_test.go b/backend/internal/service/openvpn/openvpn_test.go index 4b0f6ad..79bce15 100644 --- a/backend/internal/service/openvpn/openvpn_test.go +++ b/backend/internal/service/openvpn/openvpn_test.go @@ -98,16 +98,47 @@ func TestBuiltInOIDCUsesStableSubjectAsOpenVPNUsername(t *testing.T) { config := map[string]interface{}{ "oidc_provider": "builtin", "oidc_issuer": "https://vpn.example.com/id", } - generated := oidcConfig(tunnel, config) + generated := oidcConfig(tunnel, config, true) if !strings.Contains(generated, `openvpn-username: "string(token.claims.sub)"`) { t.Fatalf("built-in issuer must use verified subject as username: %s", generated) } + if !strings.Contains(generated, "override-username: true") { + t.Fatal("OpenVPN 2.7 must receive the authenticated subject in its status output") + } + if generated := oidcConfig(tunnel, config, false); strings.Contains(generated, "override-username:") { + t.Fatal("OpenVPN 2.6 must not receive an unsupported username override") + } config["oidc_provider"] = "external" - if generated := oidcConfig(tunnel, config); strings.Contains(generated, "openvpn-username:") { + if generated := oidcConfig(tunnel, config, true); strings.Contains(generated, "openvpn-username:") || strings.Contains(generated, "override-username:") { t.Fatalf("external issuer username behavior must remain unchanged: %s", generated) } } +func TestOverrideUsernameRequiresOpenVPN27(t *testing.T) { + dir := t.TempDir() + t.Setenv("VPN_DATA_DIR", dir) + binDir := filepath.Join(dir, "bin") + if err := os.MkdirAll(binDir, 0o700); err != nil { + t.Fatal(err) + } + bin := filepath.Join(binDir, "openvpn") + for _, tc := range []struct { + version string + want bool + }{ + {"OpenVPN 2.6.19 test\n", false}, + {"OpenVPN 2.7.8 test\n", true}, + {"unexpected output\n", false}, + } { + if err := os.WriteFile(bin, []byte("#!/bin/sh\necho '"+strings.TrimSpace(tc.version)+"'\n"), 0o700); err != nil { + t.Fatal(err) + } + if got := supportsOverrideUsername(); got != tc.want { + t.Fatalf("version %q: supportsOverrideUsername() = %v, want %v", tc.version, got, tc.want) + } + } +} + func TestOIDCRejectsUnsafeURLs(t *testing.T) { base := map[string]interface{}{ "auth_mode": "oidc", "oidc_issuer": "https://idp.example.com/realms/a", diff --git a/docs/specs/SDD-010-openvpn-27-test-upgrade.md b/docs/specs/SDD-010-openvpn-27-test-upgrade.md new file mode 100644 index 0000000..9ce401f --- /dev/null +++ b/docs/specs/SDD-010-openvpn-27-test-upgrade.md @@ -0,0 +1,56 @@ +# SDD-010:测试环境 OpenVPN 2.7 升级与飞书身份验证 + +| 字段 | 内容 | +|---|---| +| 版本 / 级别 | 0.1 / S3 | +| 设计状态 | 已形成 | +| 实现状态 | 进行中 | +| 日期 | 2026-10-22 | +| 关联需求 | 将飞书认证后的稳定身份写入在线会话,而非显示 `UNDEF` | +| 批准状态 | 用户明确授权在测试服务器卸载旧版并升级验证 | + +## 1. 背景、目标与范围 + +服务器 Ubuntu 24.04 自带 OpenVPN 2.6.19,官方认证组件 2.3.0 的 `--openvpn.override-username` 明确要求 OpenVPN Server 2.7。现有 `openvpn-auth-oauth2.yaml` 尚未包含新生成的 `oauth2.openvpn-username`。参考 `vpn-install/openvpn-install/openvpn-install.sh` 的官方签名 APT 源方案,但服务器暂时无法连接该源。官方 GitHub 发布了 v2.7.8 源包及 SHA-256 摘要。 + +目标是在保留现有专线 ID、CA、tls-crypt 密钥、VPN 用户、飞书身份配置和客户端配置的前提下安装 2.7,启用 `override-username`,用真实重新连接验证状态。仅在确实需要重建时才删除测试专线并重新生成客户端配置;升级不要求重建。不会把 `UNDEF` 按 IP/时间猜测为某个用户。 + +## 2. 需求与约束 + +- FR-01:仅从官方可信发布取得、验证版本和 SHA-256 的 OpenVPN 2.7 构建;保留旧二进制以便回退。 +- FR-02:内置飞书专线生成稳定 subject 用户名表达式,2.7 时认证组件开启用户名覆盖;外部 OIDC 保持原有配置。 +- FR-03:重启后既要验证服务端版本和配置,也要验证认证入口及真实客户端重连后的用户名;无法模拟飞书本人授权时如实标注未通过。 +- NFR-01:现有数据库、密钥、专线实例与证书先备份,生产重启仅影响测试 OpenVPN 专线,故障时恢复旧版本与原配置。 + +## 3. 架构与方案 + +参考安装脚本只借鉴获取官方包的渠道,不运行其初始化、iptables、证书签发与服务文件逻辑,因为这些由 opsx-vpn 管理。优先使用官方签名 APT 源;若网络不通,可校验官方 GitHub Release 的源码摘要并隔离构建到独立目录,先以 `--config .../server.conf` 检验兼容,再替换可执行路径。先确认认证组件对 `override-username` 的配置语义与 2.7 服务端支持情况,更新 Go 配置生成和相关测试;专线刷新时沿用原 ID 和 PKI。 + +## 4. 接口、数据与配置契约 + +管理 API 和数据库模型不变。内置发行者的认证组件 YAML 可增加 `openvpn.override-username: true`,保留 `oauth2.openvpn-username: string(token.claims.sub)`;只有目标组件和服务端都支持时启用。旧 `.ovpn` 仍使用原 CA、tls-crypt 和端点,不需重新导入。 + +## 5. 风险与验收 + +| 风险 | 防护 / 恢复 | 观察 | +|---|---|---| +| 网络或依赖不足 | 不卸载可用的 2.6;保留旧版并停止升级 | 来源、SHA-256、构建日志 | +| 配置语义或 2.7 不兼容 | 测试二进制和配置、实例及数据库备份、可回退版本 | 启动日志、监听与健康接口 | +| 用户认证期间短时中断 | 先确认在线连接数并记录维护窗口 | 状态文件、认证监听 | +| 用户身份仍为 `UNDEF` | 不推测身份;记录原始状态和组件日志并回滚 | 真实重连状态 Username | + +| AC | 对应需求 | 操作与预期 | 验证方式 | 结果 | +|---|---|---|---|---| +| AC-01 | FR-01、NFR-01 | 官方版本校验成功且回退文件可用 | 摘要、版本、备份检查 | 未执行 | +| AC-02 | FR-02 | 内置配置有两个身份选项,外部 OIDC 不变 | Go 测试、组件配置解析 | 未执行 | +| AC-03 | FR-03 | 重启后服务健康、OIDC 入口可用 | 状态、HTTP、日志 | 未执行 | +| AC-04 | FR-03 | 真实飞书登录后状态 Username 为稳定 subject,管理端显示对应姓名 | 客户端重连 + 状态/审计 | 未执行 | + +## 6. 发布与恢复 + +先校验包和编译、备份 `data/vpn-platform.db`、`data/secret.key`、原实例及当前 OpenVPN 二进制,检查在线客户端;再有序停止测试专线、切换二进制、刷新认证配置、启动。若失败,恢复旧二进制与原实例配置和数据库备份(仅当迁移实际需要),重启原专线,核对监听、Nginx 公网健康、OIDC 回调。清理精确目标,不删除整个 `data/`;如最终重建,将明确告知旧配置失效并重新下载。 + +## 7. 待决问题 + +- 官方 APT 源当前不可达;需验证官方源码在服务器环境能否取得且可以构建。 +- 真实飞书登录需要用户在客户端完成;没有实测状态前不能宣称身份识别已修复。