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

+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