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

+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)
}