feat(openvpn): add OIDC browser authentication and setup guide
This commit is contained in:
1 parent
5107f134dd
commit
d6a9a92ad0
13 files changed
+817
-14
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user