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 必填;请从 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 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) }