feat(openvpn): add OIDC browser authentication and setup guide

This commit is contained in:
opsx committed 2026-10-20 23:31:46 +08:00
1 parent 5107f134dd
commit d6a9a92ad0
13 files changed
+817 -14

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
# opsx-vpn 协作说明
本项目是 Go(Gin)后端、Vue 3 前端与 SQLite 组成的 VPN 专线管理平台。先理解现有行为和需求边界,再按变更影响决定设计与验证的深度。
## 变更分级与设计
| 级别 | 场景 | 要求 |
|------|------|------|
| S0 | 文案、格式等无行为变化 | 简要说明即可 |
| S1 | 缺陷修复、现有契约内重构 | 说明问题、依据、需保留的行为和验收方式 |
| S2 | 新功能、接口/配置/数据模型变化、跨模块行为变化 | 实施前按 `docs/sdd-template.md` 写完整 SDD;每项需求对应可验证的验收标准 |
| S3 | 权限、安全、数据迁移/删除、生产部署变化 | 满足 S2 要求,并写清风险、发布顺序、验证与恢复方式 |
- 按实际影响分级;调查中发现契约或风险扩大时,更新分级和设计。
- 设计可以先在任务中形成;需要长期维护或用户要求入库时,存入 `docs/specs/`。不要为了套模板阻断已经明确授权的实现工作。
- 先引用已有代码、接口、数据库结构和配置作为当前行为的依据;不编造业务规则、指标、批准记录或测试结果。未知或需决策的事项明确标记。
- SDD 是设计与验收依据,不代表已经批准、实现或验证;只有实际执行过的检查才能标为通过。
## 实施与验证
- 用户要求设计时交付设计;用户要求修改或开发时,完成获授权的实现并按风险验证。需要用户决定的阻塞事项单独提出,独立工作继续推进。
- 修改接口、数据或协议行为时,核对前后端调用、兼容性及相关测试;安全和部署变更还应检查失败场景与恢复路径。
- 保留用户未提交的文件和改动;不要用生成文件覆盖现有工作。
+12
View File
@@ -33,6 +33,18 @@ Gin + Vue3 + SQLite 的多协议 VPN 专线 Web 管理平台:**单二进制交
## 快速开始
### OpenVPN 飞书 / OIDC 浏览器认证
在「创建专线 → 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。
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 客户端)。为该公网入口配置 TLS 和反向代理,将**所有路径**转发到本机 `127.0.0.1:19090`(按实际端口调整);仅开放公网 HTTPS 与 OpenVPN 的 UDP/TCP VPN 端口,不暴露本机认证端口或管理 socket。回调由 `openvpn-auth-oauth2` 接收,**不需要**在平台的 `:18080` 增加 `/oauth2/callback`。
4. 启动专线时平台会运行独立的 OpenVPN 和认证服务进程;OIDC 凭据在数据库加密保存,认证服务的 `openvpn-auth-oauth2.yaml` 与管理密码文件在 `data/instances/<ID>-openvpn/` 内以 `0600` 保存。未安装二进制、OpenVPN 版本不足或认证服务启动失败都会报错并停止该专线。浏览器登录失败时不回退到 LDAP 或本地密码。
5. 下载 `.ovpn` 并在支持 WebAuth 的客户端试连。上游[客户端兼容性说明](https://github.com/jkroepke/openvpn-auth-oauth2/wiki/OpenVPN)列出 Windows OpenVPN Community Client 2.6+、macOS Tunnelblick 4.0 beta 10+ 和 Viscosity;OpenVPN Connect v3 仅部分兼容,应先实测。客户端需要在 VPN 建立**之前**访问公网 HTTPS 入口及身份源。用授权用户与未授权用户分别测试登录和拒绝,验证断线重连。
首次上线先创建独立测试隧道;不要直接切换现有密码隧道。OIDC 登录身份的授权由飞书应用范围或身份代理规则控制,管理端的 VPN 拨号用户列表目前仍是独立的数据模型。需要将飞书身份与 LDAP 用户逐一绑定时,应增加按稳定 `sub` 映射的授权设计,不能只按相同邮箱/用户名匹配。服务日志位于实例目录的 `oidc.log`,可能包含认证诊断信息,按敏感日志管理。
### 本地构建与运行
```bash
+254
View File
@@ -0,0 +1,254 @@
package openvpn
import (
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/store"
)
func oidcBinary() (string, bool) {
p := filepath.Join(service.BaseDir(), "bin", "openvpn-auth-oauth2")
if fi, err := os.Stat(p); err == nil && fi.Mode().IsRegular() && fi.Mode()&0o111 != 0 {
return p, true
}
return service.LookBin("openvpn-auth-oauth2")
}
func checkOIDCDependencies(openvpnBin string) error {
if _, ok := oidcBinary(); !ok {
return fmt.Errorf("OIDC 认证需要先安装 openvpn-auth-oauth2 二进制(PATH 或 data/bin/)")
}
out, err := exec.Command(openvpnBin, "--version").Output()
if err != nil {
return fmt.Errorf("无法检查 OpenVPN 版本: %w", err)
}
return validateOIDCOpenVPNVersion(out)
}
func validateOIDCOpenVPNVersion(out []byte) error {
m := regexp.MustCompile(`OpenVPN\s+(\d+)\.(\d+)\.(\d+)`).FindSubmatch(out)
if len(m) != 4 {
return fmt.Errorf("无法识别 OpenVPN 版本;OIDC 模式要求至少 2.6.2")
}
major, _ := strconv.Atoi(string(m[1]))
minor, _ := strconv.Atoi(string(m[2]))
patch, _ := strconv.Atoi(string(m[3]))
if major < 2 || (major == 2 && minor < 6) || (major == 2 && minor == 6 && patch < 2) {
return fmt.Errorf("OpenVPN %d.%d.%d 不支持此 OIDC 集成;至少需要 2.6.2", major, minor, patch)
}
return nil
}
// yamlString uses the JSON string grammar (also valid YAML) so administrator
// values cannot add unexpected keys or directives to the generated config.
func yamlString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
func oidcConfig(t *model.Tunnel, cfg map[string]interface{}) string {
dir := service.InstanceDir(t)
return fmt.Sprintf("http:\n baseurl: %s\n listen: %s\n secret: %s\noauth2:\n issuer: %s\n client:\n id: %s\n secret: %s\nopenvpn:\n 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")),
yamlString(service.Str(cfg, "oidc_issuer")),
yamlString(service.Str(cfg, "oidc_client_id")),
yamlString(service.Str(cfg, "oidc_client_secret")),
yamlString("unix://"+filepath.Join(dir, "management.sock")),
yamlString(service.Str(cfg, "oidc_management_password")))
}
func checkOIDCConflicts(t *model.Tunnel, cfg map[string]interface{}) error {
if store.DB == nil {
return nil
}
var others []model.Tunnel
if err := store.DB.Where("protocol = ? AND id <> ?", "openvpn", t.ID).Find(&others).Error; err != nil {
return err
}
for _, other := range others {
otherCfg, err := store.TunnelConfig(&other)
if err != nil || service.Str(otherCfg, "auth_mode") != "oidc" {
continue
}
if strings.EqualFold(strings.TrimRight(service.Str(otherCfg, "oidc_base_url"), "/"), strings.TrimRight(service.Str(cfg, "oidc_base_url"), "/")) {
return fmt.Errorf("认证入口已由 OpenVPN 专线 %q 使用;每条 OIDC 专线须使用不同 HTTPS 入口", other.Name)
}
if service.Num(otherCfg, "oidc_http_port", 19090) == service.Num(cfg, "oidc_http_port", 19090) {
return fmt.Errorf("本机 OIDC 认证端口已由 OpenVPN 专线 %q 使用;请为每条专线指定不同端口", other.Name)
}
}
return nil
}
func prepareOIDC(t *model.Tunnel, cfg map[string]interface{}) error {
old, err := store.TunnelSecrets(t)
if err != nil {
return err
}
if service.Str(cfg, "oidc_client_secret") == "" {
cfg["oidc_client_secret"] = old["oidc_client_secret"]
}
if service.Str(cfg, "oidc_client_secret") == "" {
return fmt.Errorf("oidc_client_secret 必填;请在飞书应用凭据中复制 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
if service.Str(cfg, key) == "" {
secret, err := service.RandomSecret(24)
if err != nil {
return err
}
cfg[key] = secret
}
}
passwordPath, err := service.WriteConfig(t, "management-password.txt", service.Str(cfg, "oidc_management_password")+"\n")
if err != nil {
return err
}
if err := os.Chmod(passwordPath, 0o600); err != nil {
return err
}
configPath, err := service.WriteConfig(t, "openvpn-auth-oauth2.yaml", oidcConfig(t, cfg))
if err != nil {
return err
}
return os.Chmod(configPath, 0o600)
}
func oidcPidFile(t *model.Tunnel) string {
return filepath.Join(service.InstanceDir(t), "oidc.pid")
}
func waitForManagement(t *model.Tunnel) error {
address := filepath.Join(service.InstanceDir(t), "management.sock")
for i := 0; i < 50; i++ {
if _, alive := service.ReadPid(t); !alive {
return fmt.Errorf("OpenVPN 在管理接口就绪前退出;请查看 instance.log")
}
conn, err := net.DialTimeout("unix", address, 100*time.Millisecond)
if err == nil {
_ = conn.Close()
return nil
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("OpenVPN 管理接口未就绪;请查看 instance.log")
}
func startOIDC(t *model.Tunnel, cfg map[string]interface{}) error {
bin, ok := oidcBinary()
if !ok {
return fmt.Errorf("openvpn-auth-oauth2 未安装")
}
logFile, err := os.OpenFile(filepath.Join(service.InstanceDir(t), "oidc.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
if err := logFile.Chmod(0o600); err != nil {
_ = logFile.Close()
return err
}
cmd := exec.Command(bin, "--config", filepath.Join(service.InstanceDir(t), "openvpn-auth-oauth2.yaml"))
cmd.Dir = service.InstanceDir(t)
cmd.Stdout, cmd.Stderr = logFile, logFile
if err := cmd.Start(); err != nil {
logFile.Close()
return fmt.Errorf("无法启动 OIDC 服务: %w", err)
}
if err := os.WriteFile(oidcPidFile(t), []byte(strconv.Itoa(cmd.Process.Pid)), 0o600); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
logFile.Close()
return err
}
if err := os.Chmod(oidcPidFile(t), 0o600); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
_ = logFile.Close()
_ = os.Remove(oidcPidFile(t))
return err
}
go func() { _ = cmd.Wait(); _ = logFile.Close() }()
address := fmt.Sprintf("127.0.0.1:%d", int(service.Num(cfg, "oidc_http_port", 19090)))
for i := 0; i < 80; i++ {
conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond)
if err == nil {
_ = conn.Close()
return nil
}
if !oidcRunning(t) {
_ = stopOIDC(t)
return fmt.Errorf("OIDC 认证服务提前退出;请检查实例目录中的 oidc.log(issuer、凭据及网络)")
}
time.Sleep(100 * time.Millisecond)
}
_ = stopOIDC(t)
return fmt.Errorf("OIDC 认证服务启动超时;请检查 oidc.log")
}
func oidcRunning(t *model.Tunnel) bool {
raw, err := os.ReadFile(oidcPidFile(t))
if err != nil {
return false
}
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil || pid <= 0 {
return false
}
proc, err := os.FindProcess(pid)
return err == nil && proc.Signal(syscall.Signal(0)) == nil && oidcProcessMatches(t, pid)
}
// On Linux a PID may be reused after an unexpected exit. Never send a signal
// to an unrelated process based only on a stale pid file.
func oidcProcessMatches(t *model.Tunnel, pid int) bool {
if runtime.GOOS != "linux" {
return true
}
raw, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
return err == nil && strings.Contains(string(raw), "openvpn-auth-oauth2") && strings.Contains(string(raw), filepath.Join(service.InstanceDir(t), "openvpn-auth-oauth2.yaml"))
}
func stopOIDC(t *model.Tunnel) error {
path := oidcPidFile(t)
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil || pid <= 0 {
return fmt.Errorf("OIDC PID 文件损坏")
}
proc, err := os.FindProcess(pid)
if err != nil {
return err
}
if proc.Signal(syscall.Signal(0)) == nil && oidcProcessMatches(t, pid) {
_ = proc.Signal(syscall.SIGTERM)
for i := 0; i < 30 && proc.Signal(syscall.Signal(0)) == nil; i++ {
time.Sleep(100 * time.Millisecond)
}
if proc.Signal(syscall.Signal(0)) == nil {
_ = proc.Kill()
}
}
return os.Remove(path)
}
+97 -10
View File
@@ -11,6 +11,7 @@ import (
"flag"
"fmt"
"net"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -109,9 +110,32 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
out["auth_mode"] = "password"
}
switch service.Str(out, "auth_mode") {
case "certificate", "password", "certificate+password":
case "certificate", "password", "certificate+password", "oidc":
default:
return nil, fmt.Errorf("auth_mode 必须为 certificate、password 或 certificate+password")
return nil, fmt.Errorf("auth_mode 必须为 certificate、password、certificate+password 或 oidc")
}
if service.Str(out, "auth_mode") == "oidc" {
for _, key := range []string{"oidc_issuer", "oidc_base_url"} {
u, err := url.Parse(service.Str(out, key))
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (key == "oidc_base_url" && u.Path != "" && u.Path != "/") {
return nil, fmt.Errorf("%s 必须是无用户信息、查询和片段的 HTTPS 地址;回调地址使用独立公网域名", key)
}
}
if service.Str(out, "oidc_client_id") == "" {
return nil, fmt.Errorf("oidc_client_id 必填")
}
if service.Num(out, "oidc_http_port", 0) == 0 {
out["oidc_http_port"] = 19090
}
p := service.Num(out, "oidc_http_port", 0)
if p < 1024 || p > 65535 || p != float64(int(p)) || p == service.Num(out, "port", 1194) {
return nil, fmt.Errorf("oidc_http_port 必须为 1024-65535 且不与 VPN 端口相同")
}
out["mfa_mode"] = "off" // 飞书 MFA 由身份提供方负责
} else {
for _, key := range []string{"oidc_issuer", "oidc_base_url", "oidc_client_id", "oidc_client_secret", "oidc_http_port", "oidc_http_secret", "oidc_management_password"} {
delete(out, key)
}
}
// MFA 模式:off 关闭;optional 用户启用后校验;required 强制。
if service.Str(out, "mfa_mode") == "" {
@@ -206,7 +230,7 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
}
if _, ok := out["duplicate_cn"]; !ok {
// 密码拨号多终端同用户名时生产常开;证书模式默认关(安全)。
out["duplicate_cn"] = service.Str(out, "auth_mode") == "password"
out["duplicate_cn"] = service.Str(out, "auth_mode") == "password" || service.Str(out, "auth_mode") == "oidc"
}
if _, ok := out["reneg_sec"]; !ok {
// 生产常见 reneg-sec 0;MFA/密码场景避免周期性 TLS 重协商。
@@ -312,6 +336,14 @@ func (s *Service) Create(t *model.Tunnel) error {
if err != nil {
return err
}
if service.Str(cfg, "auth_mode") == "oidc" {
if err := checkOIDCConflicts(t, cfg); err != nil {
return err
}
if err := prepareOIDC(t, cfg); err != nil {
return err
}
}
if _, err := service.WriteConfig(t, "server.conf", s.serverConfig(t, cfg)); err != nil {
return err
}
@@ -323,7 +355,10 @@ func (s *Service) Create(t *model.Tunnel) error {
return err
}
_ = os.Chmod(filepath.Join(service.InstanceDir(t), "init-pki.sh"), 0o700)
public, secrets := service.MergeSecrets(cfg)
if service.Str(cfg, "auth_mode") != "oidc" {
t.Secrets = "" // OpenVPN 只有 OIDC 配置使用 Secrets;切换模式时清除旧凭据
}
public, secrets := service.MergeSecrets(cfg, "oidc_client_secret", "oidc_http_secret", "oidc_management_password")
if err := service.SaveTunnelConfig(t, public, secrets); err != nil {
return err
}
@@ -400,6 +435,9 @@ func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) stri
b.WriteString("verify-client-cert none\nusername-as-common-name\n")
case "certificate+password":
b.WriteString("verify-client-cert require\n")
case "oidc":
b.WriteString("verify-client-cert none\n")
fmt.Fprintf(&b, "management %q unix %q\nmanagement-client-auth\nauth-user-pass-optional\n", filepath.Join(dir, "management.sock"), filepath.Join(dir, "management-password.txt"))
default:
b.WriteString("verify-client-cert require\n")
}
@@ -611,7 +649,7 @@ func (s *Service) GenerateUserConfig(t *model.Tunnel, user *model.VPNUser) (stri
return "", err
}
mode := service.StrOr(cfg, "auth_mode", "certificate")
if mode != "password" {
if mode != "password" && mode != "oidc" {
if err := s.IssueClientCertificate(t, user); err != nil {
return "", err
}
@@ -627,7 +665,7 @@ func (s *Service) GenerateUserConfig(t *model.Tunnel, user *model.VPNUser) (stri
return "", e
}
var cert, key string
if mode != "password" {
if mode != "password" && mode != "oidc" {
cert, e = read(filepath.Join(dir, "pki", "issued", user.Certificate+".crt"))
if e != nil {
return "", e
@@ -677,7 +715,7 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
if username == "" {
// Password-only authentication has no per-user certificate. Generate a
// reusable profile so operators can distribute it before creating users.
if service.StrOr(cfg, "auth_mode", "certificate") == "password" {
if mode := service.StrOr(cfg, "auth_mode", "certificate"); mode == "password" || mode == "oidc" {
return s.GenerateUserConfig(t, &model.VPNUser{})
}
return "", fmt.Errorf("请指定 client_username 或通过拨号用户下载其专属 .ovpn 配置")
@@ -690,13 +728,28 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
}
func (s *Service) Start(t *model.Tunnel) error {
if _, ok := findOpenVPNBin(); !ok {
openvpnBin, ok := findOpenVPNBin()
if !ok {
return fmt.Errorf("无法启动 OpenVPN:未检测到 openvpn")
}
cfg, err := store.TunnelConfig(t)
if err != nil {
return err
}
if service.Str(cfg, "auth_mode") == "oidc" {
if _, alive := service.ReadPid(t); alive {
if oidcRunning(t) {
return nil
}
if err := checkOIDCDependencies(openvpnBin); err != nil {
return err
}
if err := service.RequireTCPPortAvailable(int(service.Num(cfg, "oidc_http_port", 19090)), "OIDC 认证服务"); err != nil {
return err
}
return startOIDC(t, cfg) // OpenVPN still runs: restore browser login without rebinding VPN/NAT
}
}
if err := pkiReady(t); err != nil {
if !service.Bool(cfg, "pki_auto_init", true) {
return fmt.Errorf("无法启动 OpenVPN:PKI 尚未就绪:%w", err)
@@ -708,6 +761,14 @@ func (s *Service) Start(t *model.Tunnel) error {
if err := service.RequireRoot("OpenVPN"); err != nil {
return err
}
if service.Str(cfg, "auth_mode") == "oidc" {
if err := checkOIDCDependencies(openvpnBin); err != nil {
return err
}
if err := service.RequireTCPPortAvailable(int(service.Num(cfg, "oidc_http_port", 19090)), "OIDC 认证服务"); err != nil {
return err
}
}
if err := service.RequireIPv4Forwarding("OpenVPN"); err != nil {
return err
}
@@ -724,15 +785,34 @@ func (s *Service) Start(t *model.Tunnel) error {
return err
}
}
if err := service.StartDaemon(t, serviceMustBin("openvpn"), "--config", filepath.Join(service.InstanceDir(t), "server.conf")); err != nil {
if err := service.StartDaemon(t, openvpnBin, "--config", filepath.Join(service.InstanceDir(t), "server.conf")); err != nil {
_ = s.removeNAT(t, cfg)
return err
}
if service.Str(cfg, "auth_mode") == "oidc" {
if err := waitForManagement(t); err != nil {
_ = service.KillPid(t)
_ = s.removeNAT(t, cfg)
return err
}
if err := startOIDC(t, cfg); err != nil {
_ = service.KillPid(t)
_ = s.removeNAT(t, cfg)
return err
}
if _, alive := service.ReadPid(t); !alive {
_ = stopOIDC(t)
_ = s.removeNAT(t, cfg)
return fmt.Errorf("OIDC 服务就绪时 OpenVPN 已退出;请查看 instance.log")
}
}
return nil
}
func serviceMustBin(name string) string { p, _ := service.LookBin(name); return p }
func (s *Service) Stop(t *model.Tunnel) error {
var firstErr error
if err := stopOIDC(t); err != nil {
firstErr = err
}
if err := service.KillPid(t); err != nil {
firstErr = err
}
@@ -834,6 +914,13 @@ func (s *Service) removeNAT(t *model.Tunnel, cfg map[string]interface{}) error {
}
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
if _, alive := service.ReadPid(t); alive {
if cfg, err := store.TunnelConfig(t); err == nil && service.Str(cfg, "auth_mode") == "oidc" && !oidcRunning(t) {
st := service.NowStatus(true, "error", "OpenVPN 仍在运行,但 OIDC 认证服务离线;新连接将被拒绝,现有连接可能继续")
rx, tx := s.collectTraffic(t)
st.RxBytes = t.RxBytes + rx
st.TxBytes = t.TxBytes + tx
return st, nil
}
st := service.NowStatus(true, "running", "OpenVPN 进程运行中")
rx, tx := s.collectTraffic(t)
st.RxBytes = t.RxBytes + rx
@@ -2,15 +2,153 @@ package openvpn
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/pquerna/otp/totp"
"gorm.io/gorm"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/store"
)
func TestOIDCConfigAndSecrets(t *testing.T) {
t.Setenv("VPN_DATA_DIR", t.TempDir())
tunnel := &model.Tunnel{ID: 42, Protocol: "openvpn"}
input := map[string]interface{}{
"auth_mode": "oidc", "oidc_issuer": "https://idp.example.com/realms/team",
"oidc_base_url": "https://vpn-login.example.com", "oidc_client_id": "vpn-client",
"oidc_client_secret": "secret-\nadmin: true", "oidc_http_port": 19242,
}
cfg, err := New().Validate(input)
if err != nil {
t.Fatal(err)
}
if err := prepareOIDC(tunnel, cfg); err != nil {
t.Fatal(err)
}
conf := New().serverConfig(tunnel, cfg)
for _, required := range []string{"management-client-auth", "auth-user-pass-optional", "verify-client-cert none"} {
if !strings.Contains(conf, required) {
t.Fatalf("OIDC server config missing %q", required)
}
}
if strings.Contains(conf, "auth-user-pass-verify") || strings.Contains(conf, "secret-") {
t.Fatal("OIDC server config must not use password helper or leak secrets")
}
path := filepath.Join(service.InstanceDir(tunnel), "openvpn-auth-oauth2.yaml")
bytes, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(bytes), `secret: "secret-\nadmin: true"`) || strings.Contains(string(bytes), "\nadmin: true\n") {
t.Fatal("OIDC client secret must remain one quoted YAML value")
}
if stat, err := os.Stat(path); err != nil || stat.Mode().Perm() != 0o600 {
t.Fatalf("auth configuration must be 0600: %v %v", stat, err)
}
passwordPath := filepath.Join(service.InstanceDir(tunnel), "management-password.txt")
if stat, err := os.Stat(passwordPath); err != nil || stat.Mode().Perm() != 0o600 {
t.Fatalf("management password must be 0600: %v %v", stat, err)
}
public, secrets := service.MergeSecrets(cfg, "oidc_client_secret", "oidc_http_secret", "oidc_management_password")
if public["oidc_client_secret"] != nil || secrets["oidc_client_secret"] == nil {
t.Fatal("OIDC credentials must not be kept in public config")
}
tunnel.Config = service.ConfigJSON(public)
dir := service.InstanceDir(tunnel)
if err := os.MkdirAll(filepath.Join(dir, "pki"), 0o700); err != nil {
t.Fatal(err)
}
for path, content := range map[string]string{"pki/ca.crt": "test-ca\n", "ta.key": "test-tls-key\n"} {
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
client, err := New().GenerateClientConfig(tunnel)
if err != nil {
t.Fatal(err)
}
if strings.Contains(client, "auth-user-pass") || strings.Contains(client, "<cert>") || !strings.Contains(client, "<tls-crypt>") {
t.Fatal("OIDC client profile must use WebAuth without password or client certificate")
}
}
func TestOIDCRejectsUnsafeURLs(t *testing.T) {
base := map[string]interface{}{
"auth_mode": "oidc", "oidc_issuer": "https://idp.example.com/realms/a",
"oidc_base_url": "https://login.example.com", "oidc_client_id": "vpn",
}
for _, value := range []string{"http://login.example.com", "https://login.example.com/extra", "https://user@login.example.com", "https://login.example.com/?code=abc"} {
cfg := service.CloneConfig(base)
cfg["oidc_base_url"] = value
if _, err := New().Validate(cfg); err == nil {
t.Errorf("expected invalid OIDC callback base URL: %q", value)
}
}
if _, err := New().Validate(base); err != nil {
t.Fatal(err)
}
base["auth_mode"] = "password"
base["oidc_client_secret"] = "must-not-survive"
cfg, err := New().Validate(base)
if err != nil {
t.Fatal(err)
}
if _, exists := cfg["oidc_client_secret"]; exists {
t.Fatal("switching away from OIDC must remove its credentials")
}
}
func TestOIDCRequiresOpenVPN262(t *testing.T) {
for _, version := range []string{"OpenVPN 2.5.11 x86_64", "OpenVPN 2.6.1 x86_64", "unknown"} {
if err := validateOIDCOpenVPNVersion([]byte(version)); err == nil {
t.Errorf("expected %q to fail minimum version check", version)
}
}
for _, version := range []string{"OpenVPN 2.6.2 x86_64", "OpenVPN 2.7.0 x86_64", "OpenVPN 3.0.0 x86_64"} {
if err := validateOIDCOpenVPNVersion([]byte(version)); err != nil {
t.Errorf("expected %q to pass: %v", version, err)
}
}
}
func TestOIDCTunnelsNeedDistinctCallbackAndPort(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
oldDB := store.DB
store.DB = db
t.Cleanup(func() { store.DB = oldDB })
if err := db.AutoMigrate(&model.Tunnel{}); err != nil {
t.Fatal(err)
}
first := model.Tunnel{Name: "first", Protocol: "openvpn", Config: service.ConfigJSON(map[string]interface{}{
"auth_mode": "oidc", "oidc_base_url": "https://login.example.com", "oidc_http_port": 19090,
})}
if err := db.Create(&first).Error; err != nil {
t.Fatal(err)
}
other := &model.Tunnel{ID: first.ID + 1, Protocol: "openvpn"}
for _, cfg := range []map[string]interface{}{
{"oidc_base_url": "https://login.example.com/", "oidc_http_port": 19091},
{"oidc_base_url": "https://another.example.com", "oidc_http_port": 19090},
} {
if err := checkOIDCConflicts(other, cfg); err == nil {
t.Errorf("expected conflict: %#v", cfg)
}
}
if err := checkOIDCConflicts(other, map[string]interface{}{"oidc_base_url": "https://another.example.com", "oidc_http_port": 19091}); err != nil {
t.Fatal(err)
}
}
func TestValidateOpenVPNModes(t *testing.T) {
cfg, err := New().Validate(map[string]interface{}{"auth_mode": "password", "subnet": "10.8.0.0 255.255.255.0"})
if err != nil {
+66
View File
@@ -0,0 +1,66 @@
# SDD 模板(S2 / S3 必填)
**何时必填:** S2(新功能、接口/配置/数据模型、跨模块行为)和 S3(权限、安全、迁移/删除、生产变更)动工前填本模板。S0 文案与 S1 契约内修复直接改代码,不套本模板。
**FR → AC:** 第 2 节每条 FR/NFR 在第 5 节有对应 AC(前置、操作、预期、验证方式)。S3 另填第 6 节发布与恢复。
本文件是当前项目的 SDD 模板。需要长期留存的设计文档可按本模板存入 `docs/specs/`(目录按需创建);无需另找模板或索引。设计状态、批准状态、实现状态和验证结果应分别记录,不能互相替代。
# SDD-xxx:<功能名称>
| 字段 | 内容 |
|----------------|-----------------------|
| 版本 / 变更级别 | 0.1 / S2 或 S3 |
| 设计状态 | Draft |
| 实现状态 | Not Started |
| 负责人 / 日期 | 待指定 / YYYY-MM-DD |
| 关联需求 | - |
| 批准状态 | 未批准 |
## 1. 背景、目标与范围
- **当前行为与证据**:
- **本次目标**:
- **非目标**:
- **必须保留的行为**:
## 2. 需求与约束
- FR-01:当 <条件> 时,系统必须 <行为>。
- NFR-01:<性能 / 安全 / 兼容要求及测量方式>
- 边界条件、异常场景、权限要求
## 3. 架构与方案
- 涉及模块及职责
- 关键数据流 / 调用关系
- 复用哪些现有能力
- 关键取舍及理由
- Agent 可自主决定的实现细节
## 4. 接口、数据与配置契约
(已有权威文件时直接引用路径,仅说明差异)
- 接口变更:
- 数据模型 / 迁移:
- 配置变更:
- 兼容性:
## 5. 风险与验收
### 风险
| 风险或失败场景 | 防护 / 降级 / 恢复 | 可观察信号 |
|----------------|--------------------|------------|
| | | |
### 验收标准
| AC | 对应需求 | 前置条件与操作 | 预期结果 | 验证方式 | 结果 |
|-------|----------|----------------|----------|----------|----------|
| AC-01 | FR-01 | | | | Not Run |
## 6. 发布与恢复(S3 必填)
- 前置条件(配置、迁移、备份等)
- 发布顺序与健康检查
- 回滚或前向修复步骤
- 不可逆操作说明
## 7. 待决问题
| 问题 | 是否阻塞 | 推荐方案 |
|------|----------|----------|
| | | |
+68
View File
@@ -0,0 +1,68 @@
# SDD-001:OpenVPN 浏览器 OIDC 登录
| 字段 | 内容 |
|------|------|
| 版本 / 变更级别 | 0.1 / S3(身份认证、部署) |
| 设计状态 | Draft |
| 实现状态 | 已实现本地集成;待 Linux + 飞书环境联调 |
| 负责人 / 日期 | 待指定 / 2026-10-20 |
| 关联需求 | 创建 OpenVPN 专线时配置飞书 OIDC 浏览器认证 |
| 批准状态 | 未批准;用户已明确要求开始开发 |
## 1. 背景、目标与范围
- **当前行为与证据**:`backend/internal/service/openvpn/openvpn.go` 使用 `auth-user-pass-verify` 验证本地或 LDAP 密码,`frontend/src/views/TunnelWizard.vue` 的创建向导只有证书、密码、证书+密码模式。
- **本次目标**:增加独立 OIDC 浏览器认证模式、飞书应用配置引导、OpenVPN WebAuth 所需管理接口、辅助认证服务的可部署配置与失效保护。
- **非目标**:不把飞书网页登录等同 LDAP Bind;不声称现有 LDAP 用户名与飞书身份自动绑定;不让管理平台 `/api/login` 接受飞书令牌。
- **必须保留的行为**:原有三种认证方式与客户端配置保持兼容。
## 2. 需求与约束
- FR-01:选择 OIDC 模式时,向导显示飞书应用创建、OIDC 设置、客户端及网络验收步骤;旧模式不受影响。
- FR-02:OIDC 模式生成 OpenVPN 管理接口 + WebAuth 配置,客户端不再要求本地/LDAP 密码;认证服务缺失时不能退回弱认证。
- FR-03:使用独立的 `openvpn-auth-oauth2` 服务;浏览器通过外网可达 HTTPS 地址接收 OIDC 回调,管理接口只在本机使用。
- FR-04:身份提供方必须显式限制可访问该应用的人员;VPN 拨号用户列表仍独立,不能凭同名邮箱自动认作 LDAP 用户。
- NFR-01:OIDC 凭据及管理密码不可出现在隧道公开 JSON、客户端配置或普通日志,配置文件权限为 0600。
- NFR-02:不支持浏览器 WebAuth 的客户端应有明确兼容性提示;OpenVPN 服务端版本需满足上游最低 2.6.2 要求。
## 3. 架构与方案
- 在原有 `auth_mode` 增加 `oidc`,单独生成 `management-client-auth`、`auth-user-pass-optional` 和本机 Unix 管理 socket,禁用密码校验脚本;OpenVPN 基础 PKI 和 `tls-crypt` 沿用。
- `openvpn-auth-oauth2` 使用独立 HTTPS 反代入口、飞书(或兼容身份代理)的 OIDC issuer、Client ID/Secret 与本机管理 socket。每个 OIDC 隧道使用独立回调主机或端口、管理 socket 与认证服务实例。
- 在向导和编辑页面提供步骤提示,告知回调地址属于认证服务,并标明管理员应在飞书开放平台创建应用、配置登录与人员授权,确认标准 OIDC 发现文档及客户端支持。
- 安全边界:OIDC 的人员授权以飞书应用可用范围或身份代理群组为准;本期不以现有 LDAP 用户表做未经验证的身份匹配。
## 4. 接口、数据与配置契约
- 接口变更:现有隧道 API `config.auth_mode` 新值 `oidc`;不新增虚假的 `/oauth2/callback` 后端接口。
- 数据模型 / 迁移:沿用隧道配置 JSON 与 Secrets 密文,无强制数据库迁移。
- 配置变更:OIDC issuer、client ID、client secret、外网 HTTPS base URL、认证服务本地监听端口;生成认证服务配置和管理 socket 密码文件。
- 兼容性:其余认证模式行为不变;OIDC 仅供支持 WebAuth 的客户端使用。
## 5. 风险与验收
### 风险
| 风险或失败场景 | 防护 / 降级 / 恢复 | 可观察信号 |
|----------------|--------------------|------------|
| issuer 不是标准 OIDC 或飞书未授权应用 | 认证失败关闭;允许通过兼容身份代理接入 | 认证服务启动/登录错误 |
| 认证服务停机 / 回调不可达 | 不能绕过认证;恢复服务与反代 | 客户端连接失败、服务日志 |
| 使用不支持 WebAuth 的客户端 | 在向导提示已验证的客户端与版本 | 客户端不弹浏览器或登录超时 |
| 未绑定本地 LDAP 用户 | 明确由身份源应用授权控制准入;后续按稳定 subject 制定绑定设计 | 管理端用户数与实际 OIDC 会话不一致 |
### 验收标准
| AC | 对应需求 | 前置条件与操作 | 预期结果 | 验证方式 | 结果 |
|----|----------|----------------|----------|----------|------|
| AC-01 | FR-01 | 选择 OIDC / 切回密码模式 | 仅 OIDC 显示飞书和 WebAuth 提示 | 前端构建与页面检查 | 构建通过;浏览器手动检查待做 |
| AC-02 | FR-02 | 生成 OIDC 服务端和客户端配置 | 管理模式开启、无密码脚本、客户端可发起 WebAuth | Go 测试 | 单元测试通过;WebAuth 联调待做 |
| AC-03 | FR-03 | 启动认证服务,完成真实 OIDC 登录 | 浏览器回调并放行 OpenVPN | 真实 Linux 服务端 + 飞书应用 + 客户端联调 | Not Run |
| AC-04 | FR-04 | 未获应用授权的用户登录 | 被身份提供方或代理拒绝 | 真实联调 | Not Run |
| AC-05 | NFR-01 | 创建/编辑 OIDC 专线并检查文件及 API | 密钥不公开,文件 0600 | Go 测试与文件检查 | 单元测试通过;真实 API 交互待做 |
| AC-06 | NFR-02 | OpenVPN 版本过低或客户端无 WebAuth | 明确错误与提示 | Go 测试及手动客户端检查 | 版本单元测试通过;客户端手动检查待做 |
## 6. 发布与恢复(S3 必填)
- 前置条件:飞书开放平台应用获管理员发布与用户授权;OIDC discovery 可达;公网 HTTPS 域名/证书及反代;OpenVPN >=2.6.2、支持 WebAuth 的客户端、认证服务二进制。
- 发布顺序:先在测试隧道配置 OIDC 与回调,再验证失败拒绝、正确授权和断线重连;最后逐隧道切换,不批量改变现有密码隧道。
- 回滚:停用测试隧道,改回原有认证方式并重启;恢复旧客户端配置。原有隧道的密钥、PKI 与用户数据保持不变。
- 不可逆操作:本次不删除用户或凭据,不自动迁移 LDAP 认证。
## 7. 待决问题
| 问题 | 是否阻塞 | 推荐方案 |
|------|----------|----------|
| 飞书租户是否公开标准 OIDC discovery / ID token | 阻塞真实联调,不阻塞本地实现 | 先从飞书应用或身份代理获取 issuer 并校验 discovery;不兼容时使用 Keycloak/Authentik 代理 |
| 是否需要按 LDAP 用户逐一授权 OIDC | 不阻塞第一版 | 首版仅信任身份源应用的可用范围;逐一绑定必须引入稳定 subject 的独立授权方案 |
@@ -0,0 +1,66 @@
<template>
<section class="oidc-guide">
<div class="guide-title"><b>{{ $t('oidcGuide.title') }}</b><el-tag type="warning" effect="plain">{{ $t('oidcGuide.externalService') }}</el-tag></div>
<el-steps :active="guideStep" finish-status="success" align-center class="guide-steps">
<el-step :title="$t('oidcGuide.app')" :description="$t('oidcGuide.appShort')" />
<el-step :title="$t('oidcGuide.identity')" :description="$t('oidcGuide.identityShort')" />
<el-step :title="$t('oidcGuide.callback')" :description="$t('oidcGuide.callbackShort')" />
<el-step :title="$t('oidcGuide.test')" :description="$t('oidcGuide.testShort')" />
</el-steps>
<el-alert type="info" :closable="false" :title="$t('oidcGuide.appTip')" class="guide-alert">
<template #default>
<a href="https://open.feishu.cn/app" target="_blank" rel="noopener noreferrer">{{ $t('oidcGuide.openConsole') }} ↗</a>
<ol><li>{{ $t('oidcGuide.appInstruction1') }}</li><li>{{ $t('oidcGuide.appInstruction2') }}</li><li>{{ $t('oidcGuide.appInstruction3') }}</li></ol>
<p>{{ $t('oidcGuide.scopeTip') }}</p>
</template>
</el-alert>
<el-form-item :label="$t('oidcGuide.issuer')" required>
<el-input v-model.trim="config.oidc_issuer" placeholder="https://idp.example.com" />
<div class="field-tip">{{ $t('oidcGuide.issuerTip') }}</div>
</el-form-item>
<el-form-item :label="$t('oidcGuide.clientId')" required>
<el-input v-model.trim="config.oidc_client_id" />
</el-form-item>
<el-form-item :label="$t('oidcGuide.clientSecret')" :required="!editing">
<el-input v-model="config.oidc_client_secret" type="password" show-password autocomplete="new-password" :placeholder="editing ? $t('oidcGuide.keepSecret') : $t('oidcGuide.copySecret')" />
<div class="field-tip">{{ $t('oidcGuide.secretTip') }}</div>
</el-form-item>
<el-form-item :label="$t('oidcGuide.baseUrl')" required>
<el-input v-model.trim="config.oidc_base_url" placeholder="https://vpn-login.example.com" />
<div class="field-tip">{{ $t('oidcGuide.baseTip') }}</div>
</el-form-item>
<el-form-item :label="$t('oidcGuide.listenPort')">
<el-input-number v-model="config.oidc_http_port" :min="1024" :max="65535" />
<div class="field-tip">{{ $t('oidcGuide.portTip') }}</div>
</el-form-item>
<el-alert type="warning" :closable="false" class="guide-alert" :title="$t('oidcGuide.callbackTitle')">
<template #default>
<code>{{ callbackUrl }}</code>
<p>{{ $t('oidcGuide.callbackTip', { port: config.oidc_http_port || 19090 }) }}</p>
</template>
</el-alert>
<el-alert type="info" :closable="false" :title="$t('oidcGuide.testTip')" />
</section>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({ config: { type: Object, required: true }, editing: { type: Boolean, default: false } })
const callbackUrl = computed(() => `${(props.config.oidc_base_url || 'https://vpn-login.example.com').replace(/\/+$/, '')}/oauth2/callback`)
const guideStep = computed(() => !props.config.oidc_client_id ? 0
: !props.config.oidc_issuer || (!props.config.oidc_client_secret && !props.editing) ? 1
: !props.config.oidc_base_url ? 2 : 3)
</script>
<style scoped>
.oidc-guide { margin: 10px 0 24px; padding: 18px; border: 1px solid var(--border-base); border-radius: var(--radius-sm); background: var(--bg-inset); }
.guide-title { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.guide-steps { margin: 24px 0; }
.guide-alert { margin: 16px 0; }
.guide-alert p { margin: 8px 0 0; line-height: 1.5; }
.guide-alert ol { padding-left: 20px; line-height: 1.7; }
.field-tip { margin-top: 4px; color: var(--text-secondary); font-size: 12px; line-height: 1.5; }
code { overflow-wrap: anywhere; }
@media (max-width: 720px) { .guide-steps { overflow-x: auto; justify-content: flex-start; } .guide-steps :deep(.el-step) { min-width: 120px; } }
</style>
+36 -1
View File
@@ -221,6 +221,40 @@
"trafficTrend": "Traffic Trend",
"noTrafficData": "No traffic data yet (waiting for samples…)"
},
"oidcGuide": {
"title": "Feishu / OIDC browser authentication",
"externalService": "Requires openvpn-auth-oauth2",
"app": "Create app",
"appShort": "Feishu developer console",
"identity": "Enter identity settings",
"identityShort": "OIDC and credentials",
"callback": "Set public callback",
"callbackShort": "HTTPS reverse proxy",
"test": "Test client",
"testShort": "Browser sign-in",
"appTip": "Ask a Feishu administrator to create and publish an app with the appropriate sign-in capability.",
"openConsole": "Open Feishu app console",
"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.",
"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",
"clientSecret": "OIDC Client Secret",
"copySecret": "Copy from the app; 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.",
"listenPort": "Local auth service port",
"portTip": "The service listens on 127.0.0.1 only; each tunnel needs a distinct port.",
"callbackTitle": "Register this callback with the final OIDC provider",
"callbackTip": "With an identity broker, register this URL in the broker's OIDC client, and register the broker's upstream callback in Feishu. Reverse proxy the public endpoint to 127.0.0.1:{port}; :18080 does not handle this callback.",
"testTip": "Install openvpn-auth-oauth2 and OpenVPN ≥ 2.6.2, then test with a WebAuth-capable client (e.g. OpenVPN Community for Windows or Tunnelblick/Viscosity for macOS). OpenVPN Connect v3 needs individual testing.",
"startFailed": "Configuration generated, but the tunnel did not start. Resolve the error below and retry from the tunnel list.",
"fillRequired": "Enter the OIDC issuer, app credentials, and public sign-in URL"
},
"tunnelEditor": {
"title": "Edit Tunnel",
"backToList": "Back to List",
@@ -993,7 +1027,8 @@
"options": {
"certificate": "Certificate Only",
"password": "Username + Password",
"certificate+password": "Certificate + Password"
"certificate+password": "Certificate + Password",
"oidc": "Third-party OAuth2 / OIDC (Feishu)"
}
},
"mfa_mode": {
+36 -1
View File
@@ -221,6 +221,40 @@
"trafficTrend": "流量趋势",
"noTrafficData": "暂无流量数据(等待采样中…)"
},
"oidcGuide": {
"title": "飞书 / OIDC 浏览器认证配置",
"externalService": "需要 openvpn-auth-oauth2",
"app": "创建应用",
"appShort": "飞书开放平台",
"identity": "填写身份信息",
"identityShort": "OIDC 和应用凭据",
"callback": "设置公网回调",
"callbackShort": "HTTPS 与反向代理",
"test": "客户端试连",
"testShort": "浏览器登录验证",
"appTip": "先由飞书管理员在开放平台创建应用,启用身份登录所需能力,并发布应用。",
"openConsole": "打开飞书开放平台的应用管理",
"appInstruction1": "创建企业自建应用,获取应用凭据,并启用登录所需的网页授权能力。",
"appInstruction2": "在应用的安全/重定向地址设置中登记回调;若使用身份代理,这里应填代理给飞书的上游回调地址。",
"appInstruction3": "发布应用,并在可用范围中只授权允许使用此 VPN 的人员。",
"scopeTip": "在飞书侧明确设置应用可用人员与授权范围。现有 LDAP 拨号用户不会自动关联飞书身份;如飞书未提供兼容的标准 OIDC 发现地址,请先部署 Keycloak / Authentik 身份代理。",
"issuer": "OIDC 发行者地址",
"issuerTip": "填写能提供标准 OIDC discovery(/.well-known/openid-configuration)和 ID Token 的 HTTPS 地址;不要填写飞书授权页面地址。",
"clientId": "OIDC Client ID",
"clientSecret": "OIDC Client Secret",
"copySecret": "从应用凭据复制,保存后不回显",
"keepSecret": "留空保持原密钥",
"secretTip": "仅保存在服务端加密存储与 0600 的认证服务配置文件内。",
"baseUrl": "认证入口公网地址",
"baseTip": "填写独立 HTTPS 域名根地址;客户端未连上 VPN 时也必须能访问。每条 OIDC 专线使用独立入口。",
"listenPort": "本机认证服务端口",
"portTip": "认证服务仅监听 127.0.0.1;多条专线各用不同端口。",
"callbackTitle": "在最终 OIDC 身份提供方的客户端中登记此回调地址",
"callbackTip": "如果通过身份代理接入飞书,本地址登记在代理的 OIDC 客户端中,飞书应用应登记代理的上游回调。将公网入口的所有路径反向代理至本机 127.0.0.1:{port};不由 :18080 处理。",
"testTip": "安装 openvpn-auth-oauth2 与 OpenVPN ≥ 2.6.2 后启动专线;使用支持 WebAuth 的客户端试连(如 Windows OpenVPN Community、macOS Tunnelblick/Viscosity)。OpenVPN Connect v3 部分兼容,须实测。",
"startFailed": "配置已生成,但专线未成功启动;处理下方错误后从专线列表重新启动",
"fillRequired": "请填写 OIDC 地址、应用凭据和公网认证入口"
},
"tunnelEditor": {
"title": "编辑专线",
"backToList": "返回列表",
@@ -993,7 +1027,8 @@
"options": {
"certificate": "仅证书",
"password": "用户名 + 密码",
"certificate+password": "证书 + 密码"
"certificate+password": "证书 + 密码",
"oidc": "第三方 OAuth2 / OIDC(飞书登录)"
}
},
"mfa_mode": {
+3 -2
View File
@@ -161,13 +161,14 @@ export const PROTOCOL_META = {
{ key: 'auth_mode', label: 'protocols.openvpn.fields.auth_mode.label', type: 'select', options: [
{ value: 'certificate', label: 'protocols.openvpn.fields.auth_mode.options.certificate' },
{ value: 'password', label: 'protocols.openvpn.fields.auth_mode.options.password' },
{ value: 'certificate+password', label: 'protocols.openvpn.fields.auth_mode.options.certificate+password' }
{ value: 'certificate+password', label: 'protocols.openvpn.fields.auth_mode.options.certificate+password' },
{ value: 'oidc', label: 'protocols.openvpn.fields.auth_mode.options.oidc' }
], default: 'password', tip: 'protocols.openvpn.fields.auth_mode.tip' },
{ key: 'mfa_mode', label: 'protocols.openvpn.fields.mfa_mode.label', type: 'select', options: [
{ value: 'off', label: 'protocols.openvpn.fields.mfa_mode.options.off' },
{ value: 'optional', label: 'protocols.openvpn.fields.mfa_mode.options.optional' },
{ value: 'required', label: 'protocols.openvpn.fields.mfa_mode.options.required' }
], default: 'off', tip: 'protocols.openvpn.fields.mfa_mode.tip', showIf: { auth_mode: ['password', 'certificate+password'] } },
], default: 'off', tip: 'protocols.openvpn.fields.mfa_mode.tip', showIf: { auth_mode: ['certificate', 'password', 'certificate+password'] } },
{ key: 'otp_slop', label: 'protocols.openvpn.fields.otp_slop.label', type: 'number', default: 90, showIf: { auth_mode: ['password', 'certificate+password'] } },
{ key: 'otp_max_failures', label: 'protocols.openvpn.fields.otp_max_failures.label', type: 'number', default: 5, showIf: { auth_mode: ['password', 'certificate+password'] } },
{ key: 'otp_lockout_sec', label: 'protocols.openvpn.fields.otp_lockout_sec.label', type: 'number', default: 300, showIf: { auth_mode: ['password', 'certificate+password'] } },
+9
View File
@@ -29,6 +29,7 @@
<el-input v-else v-model="form.config[field.key]" :placeholder="field.placeholder || field.default" />
<div v-if="field.tip" class="field-tip">{{ $t(field.tip) }}</div>
</el-form-item>
<OpenVPNOIDCGuide v-if="isOpenVPN && form.config.auth_mode === 'oidc'" :config="form.config" :editing="originalAuthMode === 'oidc'" />
<template v-if="isOpenVPN">
<el-form-item :label="$t('tunnelEditor.trafficMode')">
<el-radio-group v-model="trafficMode">
@@ -77,6 +78,7 @@ import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api from '../api'
import RouteListInput from '../components/RouteListInput.vue'
import OpenVPNOIDCGuide from '../components/OpenVPNOIDCGuide.vue'
import { CUSTOM_FIELDS, PROTOCOL_META, protocolColor, protocolDisplay, optionValue, optionLabel } from '../protocols'
const { t } = useI18n()
@@ -85,6 +87,7 @@ const router = useRouter()
const loading = ref(true)
const saving = ref(false)
const protocol = ref('')
const originalAuthMode = ref('')
const form = ref(null)
const advancedOpen = ref([])
const routeListRef = ref(null)
@@ -134,6 +137,7 @@ async function load() {
const tunnel = data.tunnel
protocol.value = tunnel.protocol
const config = parseConfig(tunnel.config)
originalAuthMode.value = config.auth_mode || ''
for (const field of (PROTOCOL_META[protocol.value]?.fields || CUSTOM_FIELDS)) {
if (config[field.key] === undefined && field.default !== undefined) config[field.key] = field.default
}
@@ -154,6 +158,11 @@ async function load() {
async function save() {
if (!form.value.name) return ElMessage.warning(t('tunnelEditor.fillName'))
if (isOpenVPN.value && form.value.config.auth_mode === 'oidc') {
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_base_url', ...(originalAuthMode.value === 'oidc' ? [] : ['oidc_client_secret'])]) {
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
}
}
trafficMode.value = trafficMode.value
if (isOpenVPN.value && routeListRef.value && !routeListRef.value.validate()) return
saving.value = true
+9
View File
@@ -62,6 +62,7 @@
<el-input v-else v-model="form.config[f.key]" :placeholder="f.placeholder || f.default" />
<div class="field-tip" v-if="f.tip">{{ $t(f.tip) }}</div>
</el-form-item>
<OpenVPNOIDCGuide v-if="isOpenVPN && form.config.auth_mode === 'oidc'" :config="form.config" />
<template v-if="isOpenVPN">
<el-form-item :label="$t('tunnelWizard.trafficMode')">
<el-radio-group v-model="trafficMode">
@@ -113,6 +114,8 @@
<!-- 第三步:结果 -->
<div v-if="step === 2" style="max-width:720px;margin:0 auto">
<el-result icon="success" :title="$t('tunnelWizard.createSuccess')" :sub-title="$t('tunnelWizard.configGenerated', { name: created?.name })" />
<el-alert v-if="created?.protocol === 'openvpn' && created?.state === 'error'" type="warning" :closable="false"
:title="$t('oidcGuide.startFailed')" :description="created.last_error" style="margin-bottom:18px" />
<h4>{{ $t('tunnelWizard.clientConfig') }}</h4>
<pre class="mono conf-box">{{ clientConfig }}</pre>
<div style="display:flex;gap:12px;justify-content:center;margin-top:16px">
@@ -159,6 +162,7 @@ import QRCode from 'qrcode'
import api from '../api'
import ProtocolIcon from '../components/ProtocolIcon.vue'
import RouteListInput from '../components/RouteListInput.vue'
import OpenVPNOIDCGuide from '../components/OpenVPNOIDCGuide.vue'
import { PROTOCOL_META, CUSTOM_FIELDS, protocolColor, optionValue, optionLabel } from '../protocols'
const { t } = useI18n()
@@ -225,6 +229,11 @@ function selectProtocol(key) {
async function create() {
if (!form.value.name) return ElMessage.warning(t('tunnelWizard.fillName'))
if (isOpenVPN.value && form.value.config.auth_mode === 'oidc') {
for (const key of ['oidc_issuer', 'oidc_client_id', 'oidc_client_secret', 'oidc_base_url']) {
if (!form.value.config[key]) return ElMessage.warning(t('oidcGuide.fillRequired'))
}
}
trafficMode.value = trafficMode.value
if (isOpenVPN.value && routeListRef.value && !routeListRef.value.validate()) return
for (const f of visibleFields.value) {