272 lines
9.0 KiB
Go
272 lines
9.0 KiB
Go
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)
|
||
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
|
||
}
|
||
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
|
||
}
|
||
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)
|
||
}
|