Files
opsx-vpn/backend/internal/service/openvpn/oidc.go
T

285 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package openvpn
import (
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"syscall"
"time"
"vpn-platform/backend/internal/identityprovider"
"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)
usernameConfig := ""
if service.Str(cfg, "oidc_provider") == "builtin" {
usernameConfig = " openvpn-username: \"string(token.claims.sub)\"\n"
}
return fmt.Sprintf("http:\n baseurl: %s\n listen: %s\n secret: %s\n template: %s\noauth2:\n%s 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(filepath.Join(dir, "auth-result.html")),
usernameConfig,
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
}
oldCfg, err := store.TunnelConfig(t)
if err != nil {
return err
}
builtin := service.Str(cfg, "oidc_provider") == "builtin"
if !builtin && service.Str(oldCfg, "oidc_provider") != "builtin" && service.Str(cfg, "oidc_client_secret") == "" {
cfg["oidc_client_secret"] = old["oidc_client_secret"]
}
if builtin {
issuer, clientID, ready := identityprovider.PublicClient()
if !ready || issuer != service.Str(cfg, "oidc_issuer") || clientID != service.Str(cfg, "oidc_client_id") || strings.TrimSuffix(issuer, "/id") != strings.TrimRight(service.Str(cfg, "oidc_base_url"), "/") {
return fmt.Errorf("内置飞书身份服务未启用或与当前专线配置不匹配;请在系统设置中启用后重新选择")
}
secret, ok := identityprovider.ClientForIssuer(issuer, clientID)
if !ok {
return fmt.Errorf("无法获取内置 VPN 客户端凭据")
}
cfg["oidc_client_secret"] = 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
}
pagePath, err := service.WriteConfig(t, "auth-result.html", authResultPage)
if err != nil {
return err
}
if err := os.Chmod(pagePath, 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)
}