// Package openvpn manages a real OpenVPN server, its Easy-RSA PKI and dial-in // identities. It never creates a placeholder process when host dependencies // are missing. package openvpn import ( "crypto/rand" "encoding/base64" "encoding/json" "errors" "flag" "fmt" "net" "net/url" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "sync" "syscall" "time" "github.com/go-ldap/ldap/v3" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "vpn-platform/backend/internal/core" "vpn-platform/backend/internal/ldaputil" "vpn-platform/backend/internal/model" "vpn-platform/backend/internal/service" "vpn-platform/backend/internal/sessionmgr" "vpn-platform/backend/internal/store" ) // TOTP 参数(RFC 6238 标准:sha1 / 30s / 6 位 / base32),兼容主流 Authenticator 应用。 const ( otpPeriod = 30 otpDigits = 6 otpDefaultSlop = 90 pkiExpireDays = 99 * 365 ) type Service struct{} var certificateName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`) func New() *Service { return &Service{} } func (s *Service) Name() string { return "openvpn" } func (s *Service) DisplayName() string { return "OpenVPN" } // findOpenVPNBin 查找 openvpn 二进制:优先 data/bin/(平台下载),其次 PATH(系统安装) func findOpenVPNBin() (string, bool) { managed := filepath.Join(service.BaseDir(), "bin", "openvpn") if fi, err := os.Stat(managed); err == nil && !fi.IsDir() { return managed, true } return service.LookBin("openvpn") } func (s *Service) Available() (bool, string) { if p, ok := findOpenVPNBin(); ok { return true, p } return false, "未检测到 openvpn 二进制,点击「下载部署」可自动从 GitHub 下载" } func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) { out := service.CloneConfig(cfg) if service.Num(out, "port", 0) == 0 { out["port"] = 1194 } if p := service.Num(out, "port", 0); p < 1 || p > 65535 || p != float64(int(p)) { return nil, fmt.Errorf("port 必须是 1-65535 的整数") } if service.Str(out, "proto") == "" { out["proto"] = "tcp-server" } // 前端/API 可用 udp | tcp | tcp-server;服务端统一写成 udp 或 tcp-server。 switch strings.ToLower(service.Str(out, "proto")) { case "tcp", "tcp-server", "tcp-client": out["proto"] = "tcp-server" case "udp": out["proto"] = "udp" default: return nil, fmt.Errorf("proto 必须为 udp 或 tcp") } if service.Str(out, "subnet") == "" { out["subnet"] = "10.10.0.0 255.255.255.0" } if _, _, err := parseSubnet(service.Str(out, "subnet")); err != nil { return nil, err } if service.Str(out, "cipher") == "" { out["cipher"] = "AES-256-GCM" } if !regexp.MustCompile(`^[A-Za-z0-9-]+$`).MatchString(service.Str(out, "cipher")) { return nil, fmt.Errorf("cipher 非法") } if service.Str(out, "dns") == "" { out["dns"] = service.DefaultDNS() } if net.ParseIP(service.Str(out, "dns")) == nil { return nil, fmt.Errorf("dns 必须是 IP 地址") } if service.Str(out, "auth_mode") == "" { out["auth_mode"] = "password" } switch service.Str(out, "auth_mode") { case "certificate", "password", "certificate+password", "oidc": default: 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") == "" { if service.Bool(out, "require_mfa", false) { out["mfa_mode"] = "required" } else { out["mfa_mode"] = "off" } } switch service.Str(out, "mfa_mode") { case "off", "optional", "required": default: return nil, fmt.Errorf("mfa_mode 必须为 off、optional 或 required") } if service.Str(out, "mfa_mode") != "off" { mode := service.Str(out, "auth_mode") if mode != "password" && mode != "certificate+password" { return nil, fmt.Errorf("启用 MFA 需要认证方式为「仅用户名密码」或「证书 + 密码」") } } // otp_slop:允许时钟偏差秒数(默认 180),换算为 TOTP skew 窗口数。 if service.Num(out, "otp_slop", 0) == 0 { out["otp_slop"] = 90 } if slop := int(service.Num(out, "otp_slop", 90)); slop < 0 || slop > 600 { return nil, fmt.Errorf("otp_slop 必须在 0-600 秒") } if service.Num(out, "otp_max_failures", 0) == 0 { out["otp_max_failures"] = 5 } if service.Num(out, "otp_lockout_sec", 0) == 0 { out["otp_lockout_sec"] = 300 } if _, ok := out["otp_disallow_reuse"]; !ok { out["otp_disallow_reuse"] = true } if _, ok := out["otp_password_is_cr"]; !ok { // 默认 password_is_cr=1:期望 static-challenge / SCRV1 响应格式。 out["otp_password_is_cr"] = true } if normalized, err := normalizePushRoutes(service.Str(out, "push_routes")); err != nil { return nil, err } else { out["push_routes"] = normalized } if _, ok := out["redirect_gateway"]; !ok { // 未填有效路由时默认全流量;只有注释不应改变默认出口。 routes, _ := parsePushRoutes(service.Str(out, "push_routes")) out["redirect_gateway"] = len(routes) == 0 } if _, ok := out["pki_auto_init"]; !ok { out["pki_auto_init"] = true } if _, ok := out["enable_nat"]; !ok { out["enable_nat"] = true } if service.Bool(out, "enable_nat", true) && service.Str(out, "egress_interface") == "" { if iface, err := service.DefaultRouteInterface(); err == nil { out["egress_interface"] = iface } } // —— 生产 server.conf 常用调优(可借鉴参数)—— if service.Num(out, "keepalive_interval", 0) == 0 { out["keepalive_interval"] = 5 // 生产常见 5 30,比默认 10 120 更灵敏 } if service.Num(out, "keepalive_timeout", 0) == 0 { out["keepalive_timeout"] = 30 } ki, kt := int(service.Num(out, "keepalive_interval", 5)), int(service.Num(out, "keepalive_timeout", 30)) if ki < 1 || ki > 3600 || kt < ki || kt > 7200 { return nil, fmt.Errorf("keepalive 参数无效:interval 1-3600,timeout >= interval 且 ≤7200") } if service.Num(out, "tun_mtu", 0) == 0 { out["tun_mtu"] = 1500 } if mtu := int(service.Num(out, "tun_mtu", 1500)); mtu < 576 || mtu > 9000 { return nil, fmt.Errorf("tun_mtu 必须在 576-9000") } if service.Num(out, "tun_mtu_extra", 0) == 0 { out["tun_mtu_extra"] = 32 } // TCP 场景生产常用 mssfix 1450,避免分片;UDP 也可设。0 表示不写该指令。 if _, ok := out["mssfix"]; !ok { if service.Str(out, "proto") == "tcp-server" { out["mssfix"] = 1450 } else { out["mssfix"] = 0 } } if mf := int(service.Num(out, "mssfix", 0)); mf != 0 && (mf < 500 || mf > 9000) { return nil, fmt.Errorf("mssfix 为 0(禁用)或 500-9000") } if _, ok := out["duplicate_cn"]; !ok { // 密码拨号多终端同用户名时生产常开;证书模式默认关(安全)。 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 重协商。 if service.Str(out, "auth_mode") != "certificate" || service.Str(out, "mfa_mode") != "off" { out["reneg_sec"] = 0 } else { out["reneg_sec"] = 3600 } } if rs := int(service.Num(out, "reneg_sec", 0)); rs < 0 || rs > 86400 { return nil, fmt.Errorf("reneg_sec 必须在 0-86400") } if service.Num(out, "verb", 0) == 0 { out["verb"] = 3 } // 可选降权(生产 user openvpn / group openvpn);空则不写,避免非 root 启动失败。 for _, key := range []string{"run_as_user", "run_as_group"} { if v := service.Str(out, key); v != "" && !regexp.MustCompile(`^[a-zA-Z0-9_-]{1,32}$`).MatchString(v) { return nil, fmt.Errorf("%s 非法", key) } } if service.Num(out, "max_clients", 0) == 0 { // 0 表示不限制;生产常设 1000,这里不强制默认值以免改变既有语义。 } return out, nil } func normalizePushRoutes(raw string) (string, error) { lines := make([]string, 0) for _, line := range strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") { trimmed := strings.TrimSpace(line) if trimmed == "" { continue } comment, routePart := "", trimmed if idx := strings.Index(routePart, "#"); idx >= 0 { comment, routePart = strings.TrimSpace(routePart[idx:]), strings.TrimSpace(routePart[:idx]) } routes, err := parsePushRoutes(routePart) if err != nil { return "", err } if len(routes) == 0 && comment != "" { lines = append(lines, comment) continue } if len(routes) > 0 { line = strings.Join(routes, ", ") if comment != "" { line += " " + comment } lines = append(lines, line) } } return strings.Join(lines, "\n"), nil } func parsePushRoutes(raw string) ([]string, error) { routes := make([]string, 0) for _, line := range strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") { if idx := strings.Index(line, "#"); idx >= 0 { line = line[:idx] } for _, route := range strings.Split(line, ",") { route = strings.TrimSpace(route) if route == "" { continue } if !strings.Contains(route, "/") { ip := net.ParseIP(route).To4() if ip == nil { return nil, fmt.Errorf("push_routes 含非法 IP 或 CIDR: %s", route) } route = ip.String() + "/32" } ip, network, err := net.ParseCIDR(route) if err != nil || ip.To4() == nil { return nil, fmt.Errorf("push_routes 含非法 IPv4 CIDR: %s", route) } routes = append(routes, ip.Mask(network.Mask).String()+"/"+strconv.Itoa(maskSize(network.Mask))) } } return routes, nil } func maskSize(mask net.IPMask) int { size, _ := mask.Size(); return size } func parseSubnet(v string) (net.IP, net.IPMask, error) { f := strings.Fields(v) if len(f) != 2 { return nil, nil, fmt.Errorf("subnet 必须为“网络地址 掩码”,例如 10.10.0.0 255.255.255.0") } ip := net.ParseIP(f[0]).To4() mask := net.ParseIP(f[1]).To4() if ip == nil || mask == nil { return nil, nil, fmt.Errorf("subnet 必须是 IPv4 网络") } return ip, net.IPMask(mask), nil } func (s *Service) Create(t *model.Tunnel) error { cfg, err := store.TunnelConfig(t) 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 } if _, err := service.WriteConfig(t, "auth-user-pass.sh", authScript(t)); err != nil { return err } _ = os.Chmod(filepath.Join(service.InstanceDir(t), "auth-user-pass.sh"), 0o700) if _, err := service.WriteConfig(t, "init-pki.sh", pkiScript(t)); err != nil { return err } _ = os.Chmod(filepath.Join(service.InstanceDir(t), "init-pki.sh"), 0o700) 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 } // PKI creation is intentionally best-effort: a missing easy-rsa must not be // reported as a running server. Start() verifies every required artifact. _, openvpnInstalled := findOpenVPNBin() if service.Bool(cfg, "pki_auto_init", true) && easyRSAPath() != "" && openvpnInstalled && pkiReady(t) != nil { return s.InitializePKI(t) } return nil } func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) string { dir := service.InstanceDir(t) ca, cert, key, dh, ta := filepath.Join(dir, "pki", "ca.crt"), filepath.Join(dir, "pki", "issued", "server.crt"), filepath.Join(dir, "pki", "private", "server.key"), filepath.Join(dir, "pki", "dh.pem"), filepath.Join(dir, "ta.key") var b strings.Builder // 基础监听(对齐生产:proto/port/dev tun) fmt.Fprintf(&b, "port %d\nproto %s\ndev tun\n", int(service.Num(cfg, "port", 1194)), service.StrOr(cfg, "proto", "udp")) // MTU / MSS(生产:tun-mtu 1500、tun-mtu-extra 32、mssfix 1450) fmt.Fprintf(&b, "tun-mtu %d\n", int(service.Num(cfg, "tun_mtu", 1500))) if extra := int(service.Num(cfg, "tun_mtu_extra", 32)); extra > 0 { fmt.Fprintf(&b, "tun-mtu-extra %d\n", extra) } if mf := int(service.Num(cfg, "mssfix", 0)); mf > 0 { fmt.Fprintf(&b, "mssfix %d\n", mf) } // 证书与 TLS(crl-verify:吊销即时生效,对齐生产加固) crl := filepath.Join(dir, "pki", "crl.pem") fmt.Fprintf(&b, "ca %q\ncert %q\nkey %q\ndh %q\ntls-crypt %q\ncrl-verify %q\n", ca, cert, key, dh, ta, crl) // 地址池与拓扑 fmt.Fprintf(&b, "topology subnet\nserver %s\n", service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0")) // push:DNS / 全流量 / 内网路由 fmt.Fprintf(&b, "push \"dhcp-option DNS %s\"\n", service.StrOr(cfg, "dns", service.DefaultDNS())) if service.Bool(cfg, "redirect_gateway", true) { b.WriteString("push \"redirect-gateway def1 bypass-dhcp\"\n") } routes, _ := parsePushRoutes(service.Str(cfg, "push_routes")) for _, r := range routes { ip, network, err := net.ParseCIDR(r) if err != nil { continue } mask := net.IP(network.Mask).String() fmt.Fprintf(&b, "push \"route %s %s\"\n", ip.Mask(network.Mask).String(), mask) } // 会话保活(生产 keepalive 5 30) fmt.Fprintf(&b, "keepalive %d %d\n", int(service.Num(cfg, "keepalive_interval", 5)), int(service.Num(cfg, "keepalive_timeout", 30))) fmt.Fprintf(&b, "data-ciphers %s:AES-128-GCM\ndata-ciphers-fallback %s\npersist-tun\n", service.StrOr(cfg, "cipher", "AES-256-GCM"), service.StrOr(cfg, "cipher", "AES-256-GCM")) // reneg-sec:生产/MFA 常 0,避免周期性 TLS 重协商 fmt.Fprintf(&b, "reneg-sec %d\n", int(service.Num(cfg, "reneg_sec", 0))) if service.Bool(cfg, "duplicate_cn", false) { b.WriteString("duplicate-cn\n") } if maxClients := int(service.Num(cfg, "max_clients", 0)); maxClients > 0 { fmt.Fprintf(&b, "max-clients %d\n", maxClients) } // 可选降权(生产 user/group openvpn) if u := service.Str(cfg, "run_as_user"); u != "" { fmt.Fprintf(&b, "user %s\n", u) } if g := service.Str(cfg, "run_as_group"); g != "" { fmt.Fprintf(&b, "group %s\n", g) } fmt.Fprintf(&b, "status %q 10\nstatus-version 3\nverb %d\n", filepath.Join(dir, "openvpn-status.log"), int(service.Num(cfg, "verb", 3))) if service.StrOr(cfg, "proto", "udp") == "udp" { b.WriteString("explicit-exit-notify 1\n") } // 认证 switch service.StrOr(cfg, "auth_mode", "certificate") { case "password": // 对齐生产:verify-client-cert none + username-as-common-name(client-cert-not-required 已弃用) 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") } if mode := service.StrOr(cfg, "auth_mode", "certificate"); mode == "password" || mode == "certificate+password" { // via-file 比 via-env 更安全(密码不进环境变量);script-security 2 足够执行外部脚本。 // 生产若用 via-env 需 script-security 3,我们刻意保持 via-file + 2。 fmt.Fprintf(&b, "script-security 2\nauth-user-pass-verify %q via-file\n", filepath.Join(dir, "auth-user-pass.sh")) if mfaMode := service.StrOr(cfg, "mfa_mode", "off"); mfaMode == "optional" || mfaMode == "required" { b.WriteString("static-challenge \"请输入 Google Authenticator / MFA 验证码\" 1\n") } } return b.String() } // InitializePKI creates the CA and server key material in the instance's // private directory. It is safe to call repeatedly after a successful setup. func (s *Service) InitializePKI(t *model.Tunnel) error { if pkiReady(t) == nil { return nil } easy := easyRSAPath() if easy == "" { return fmt.Errorf("无法初始化 OpenVPN PKI:未检测到 easy-rsa/easyrsa") } dir := service.InstanceDir(t) if err := os.MkdirAll(dir, 0o700); err != nil { return err } // Any PKI that failed the readiness check cannot start a server safely. if err := os.RemoveAll(filepath.Join(dir, "pki")); err != nil { return fmt.Errorf("清理不完整的 OpenVPN PKI 失败: %w", err) } if err := runEasyRSA(dir, easy, "init-pki"); err != nil { return err } if err := runEasyRSA(dir, easy, "--batch", "build-ca", "nopass"); err != nil { return err } if err := runEasyRSA(dir, easy, "--batch", "build-server-full", "server", "nopass"); err != nil { return err } if err := runEasyRSA(dir, easy, "gen-dh"); err != nil { return err } if err := runEasyRSA(dir, easy, "gen-crl"); err != nil { return err } openvpn, ok := findOpenVPNBin() if !ok { return fmt.Errorf("PKI 证书已生成,但未检测到 openvpn,无法生成 tls-crypt 密钥") } cmd := exec.Command(openvpn, "--genkey", "secret", filepath.Join(dir, "ta.key")) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("生成 tls-crypt 密钥失败: %v: %s", err, out) } return pkiReady(t) } func easyRSAPath() string { if p, ok := service.LookBin("easyrsa"); ok { return p } for _, pattern := range []string{ "/usr/share/easy-rsa/easyrsa", "/usr/share/easy-rsa/*/easyrsa", "/usr/local/share/easy-rsa/easyrsa", } { paths, _ := filepath.Glob(pattern) for _, p := range paths { if st, err := os.Stat(p); err == nil && st.Mode().IsRegular() { return p } } } return "" } func runEasyRSA(dir, binary string, args ...string) error { cmd := exec.Command(binary, args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "EASYRSA_BATCH=1", fmt.Sprintf("EASYRSA_CA_EXPIRE=%d", pkiExpireDays), fmt.Sprintf("EASYRSA_CERT_EXPIRE=%d", pkiExpireDays), ) // Easy-RSA 3.1 rejects EASYRSA_REQ_CN for build-server/client-full because // those commands derive the common name from their positional filename. // It is only needed when the CA is created. if len(args) >= 2 && args[len(args)-2] == "build-ca" { cmd.Env = append(cmd.Env, "EASYRSA_REQ_CN=OPSX-VPN-CA") } out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("easy-rsa %s 失败: %v: %s", strings.Join(args, " "), err, out) } return nil } func pkiReady(t *model.Tunnel) error { for _, p := range []string{"pki/ca.crt", "pki/issued/server.crt", "pki/private/server.key", "pki/dh.pem", "ta.key"} { if _, err := os.Stat(filepath.Join(service.InstanceDir(t), p)); err != nil { return fmt.Errorf("缺少 %s", p) } } return nil } func pkiScript(t *model.Tunnel) string { easy := shellQuote(easyRSAPathOrDefault()) expires := "EASYRSA_BATCH=1 EASYRSA_CA_EXPIRE=" + strconv.Itoa(pkiExpireDays) + " EASYRSA_CERT_EXPIRE=" + strconv.Itoa(pkiExpireDays) return "#!/bin/sh\nset -eu\ncd " + shellQuote(service.InstanceDir(t)) + "\n" + easy + " init-pki\nEASYRSA_REQ_CN=OPSX-VPN-CA " + expires + " " + easy + " --batch build-ca nopass\n" + expires + " " + easy + " --batch build-server-full server nopass\n" + expires + " " + easy + " gen-dh\n" + expires + " " + easy + " gen-crl\nopenvpn --genkey secret ta.key\necho 'OpenVPN PKI initialized'\n" } func easyRSAPathOrDefault() string { if p := easyRSAPath(); p != "" { return p } return "easyrsa" } func authScript(t *model.Tunnel) string { exe, _ := os.Executable() if exe == "" { exe = "opsx-vpn" } return "#!/bin/sh\nset -eu\nexec " + shellQuote(exe) + " openvpn-auth --tunnel " + strconv.FormatUint(uint64(t.ID), 10) + " --credentials \"$1\"\n" } func shellQuote(v string) string { return "'" + strings.ReplaceAll(v, "'", "'\\''") + "'" } // RevokeClientCertificate 吊销用户证书并重新生成 CRL(禁用/删除拨号用户时调用)。 // server.conf 的 crl-verify 使吊销生效;运行中进程收 SIGUSR1 重读 CRL。 func (s *Service) RevokeClientCertificate(t *model.Tunnel, user *model.VPNUser) error { if user.Certificate == "" { return nil } easy := easyRSAPath() if easy == "" { return fmt.Errorf("未检测到 easy-rsa,无法吊销证书") } dir := service.InstanceDir(t) if _, err := os.Stat(filepath.Join(dir, "pki", "issued", user.Certificate+".crt")); err != nil { return nil } if err := runEasyRSA(dir, easy, "--batch", "revoke", user.Certificate); err != nil { return err } if err := runEasyRSA(dir, easy, "gen-crl"); err != nil { return err } if pid, alive := service.ReadPid(t); alive { if proc, err := os.FindProcess(pid); err == nil { _ = proc.Signal(syscall.SIGUSR1) } } return nil } // IssueClientCertificate signs a per-VPNUser certificate and records the exact // certificate CN. LDAP users can use certificates as a second factor too. func (s *Service) IssueClientCertificate(t *model.Tunnel, user *model.VPNUser) error { if user.TunnelID != t.ID { return errors.New("拨号用户不属于该 OpenVPN 隧道") } if !user.Enabled { return errors.New("拨号用户已禁用") } if err := pkiReady(t); err != nil { return fmt.Errorf("无法签发客户端证书:%w", err) } name := user.Certificate if name == "" { name = certificateCN(user.Username) } if !certificateName.MatchString(name) { return fmt.Errorf("客户端证书名称无效") } cert := filepath.Join(service.InstanceDir(t), "pki", "issued", name+".crt") if _, err := os.Stat(cert); err != nil { easy := easyRSAPath() if easy == "" { return fmt.Errorf("未检测到 easy-rsa,无法签发客户端证书") } if err := runEasyRSA(service.InstanceDir(t), easy, "--batch", "build-client-full", name, "nopass"); err != nil { return err } } user.Certificate = name return store.DB.Save(user).Error } func certificateCN(username string) string { v := strings.Map(func(r rune) rune { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { return r } return '-' }, username) v = strings.Trim(v, "-_") if v == "" { v = "vpn-user" } if len(v) > 48 { v = v[:48] } return "u-" + v } // GenerateUserConfig returns an immediately importable profile after signing // the user certificate. It replaces the former instructional, non-functional // profile generation. func (s *Service) GenerateUserConfig(t *model.Tunnel, user *model.VPNUser) (string, error) { cfg, err := store.TunnelConfig(t) if err != nil { return "", err } mode := service.StrOr(cfg, "auth_mode", "certificate") if mode != "password" && mode != "oidc" { if err := s.IssueClientCertificate(t, user); err != nil { return "", err } } dir := service.InstanceDir(t) read := func(p string) (string, error) { b, e := os.ReadFile(p); return string(b), e } ca, e := read(filepath.Join(dir, "pki", "ca.crt")) if e != nil { return "", e } ta, e := read(filepath.Join(dir, "ta.key")) if e != nil { return "", e } var cert, key string if mode != "password" && mode != "oidc" { cert, e = read(filepath.Join(dir, "pki", "issued", user.Certificate+".crt")) if e != nil { return "", e } key, e = read(filepath.Join(dir, "pki", "private", user.Certificate+".key")) if e != nil { return "", e } } var b strings.Builder cipher := service.StrOr(cfg, "cipher", "AES-256-GCM") fmt.Fprintf(&b, "client\ndev tun\nproto %s\nremote %s %d\nresolv-retry infinite\nnobind\npersist-tun\nremote-cert-tls server\ndata-ciphers %s:AES-128-GCM\ndata-ciphers-fallback %s\nverb 3\n", clientProto(service.StrOr(cfg, "proto", "udp")), service.StrOr(cfg, "public_endpoint", "your-server-ip"), int(service.Num(cfg, "port", 1194)), cipher, cipher) if mode == "password" || mode == "certificate+password" { b.WriteString("auth-user-pass\n") b.WriteString("auth-nocache\n") if mfaMode := service.StrOr(cfg, "mfa_mode", "off"); mfaMode == "optional" || mfaMode == "required" { b.WriteString("static-challenge \"请输入 Google Authenticator / MFA 验证码\" 1\n") } } // 与服务端 reneg-sec 一致(生产常 0) fmt.Fprintf(&b, "reneg-sec %d\n", int(service.Num(cfg, "reneg_sec", 0))) if mtu := int(service.Num(cfg, "tun_mtu", 0)); mtu > 0 { fmt.Fprintf(&b, "tun-mtu %d\n", mtu) } b.WriteString("\n" + ca + "\n") if cert != "" { b.WriteString("\n" + cert + "\n\n" + key + "\n") } b.WriteString("\n" + ta + "\n") return b.String(), nil } func clientProto(proto string) string { switch strings.ToLower(proto) { case "tcp", "tcp-server", "tcp-client": return "tcp-client" default: return "udp" } } func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) { cfg, err := store.TunnelConfig(t) if err != nil { return "", err } username := service.Str(cfg, "client_username") if username == "" { // Password-only authentication has no per-user certificate. Generate a // reusable profile so operators can distribute it before creating users. if mode := service.StrOr(cfg, "auth_mode", "certificate"); mode == "password" || mode == "oidc" { return s.GenerateUserConfig(t, &model.VPNUser{}) } return "", fmt.Errorf("请指定 client_username 或通过拨号用户下载其专属 .ovpn 配置") } var user model.VPNUser if err := store.DB.Where("tunnel_id = ? AND username = ?", t.ID, username).First(&user).Error; err != nil { return "", fmt.Errorf("拨号用户 %q 不存在: %w", username, err) } return s.GenerateUserConfig(t, &user) } func (s *Service) Start(t *model.Tunnel) error { 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) } if initErr := s.InitializePKI(t); initErr != nil { return fmt.Errorf("无法启动 OpenVPN:自动初始化 PKI 失败: %w", initErr) } } 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 } port := int(service.Num(cfg, "port", 1194)) if service.StrOr(cfg, "proto", "udp") == "udp" { if err := service.RequireUDPPortAvailable(port, "OpenVPN"); err != nil { return err } } else if err := service.RequireTCPPortAvailable(port, "OpenVPN"); err != nil { return err } if service.Bool(cfg, "enable_nat", true) { if err := s.installNAT(t, cfg); err != nil { return err } } 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 (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 } if cfg, err := store.TunnelConfig(t); err == nil && service.Bool(cfg, "enable_nat", true) { if err := s.removeNAT(t, cfg); err != nil && firstErr == nil { firstErr = err } } return firstErr } func (s *Service) installNAT(t *model.Tunnel, cfg map[string]interface{}) error { iptables, ok := service.LookBin("iptables") if !ok { return fmt.Errorf("无法启动 OpenVPN:启用 NAT 需要 iptables") } egress := service.Str(cfg, "egress_interface") if egress == "" { var err error egress, err = service.DefaultRouteInterface() if err != nil { return err } } ip, mask, err := parseSubnet(service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0")) if err != nil { return err } ones, _ := mask.Size() subnet := fmt.Sprintf("%s/%d", ip.String(), ones) comment := fmt.Sprintf("opsx-vpn-openvpn-%d", t.ID) rules := [][]string{ {"-t", "nat", "-A", "POSTROUTING", "-s", subnet, "-o", egress, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}, {"-A", "FORWARD", "-i", "tun+", "-o", egress, "-s", subnet, "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, {"-A", "FORWARD", "-i", egress, "-o", "tun+", "-d", subnet, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, } for _, rule := range rules { check := append([]string{}, rule...) // -C 检查时把 -A 换成 -C for i := 0; i < len(check); i++ { if check[i] == "-A" { check[i] = "-C" break } } if _, err := service.Run(iptables, check...); err == nil { continue } if _, err := service.Run(iptables, rule...); err != nil { _ = s.removeNAT(t, cfg) return fmt.Errorf("无法添加 OpenVPN NAT 规则: %w", err) } } return nil } func (s *Service) removeNAT(t *model.Tunnel, cfg map[string]interface{}) error { iptables, ok := service.LookBin("iptables") if !ok { return nil } egress := service.Str(cfg, "egress_interface") if egress == "" { egress, _ = service.DefaultRouteInterface() } ip, mask, err := parseSubnet(service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0")) if err != nil { return nil } ones, _ := mask.Size() subnet := fmt.Sprintf("%s/%d", ip.String(), ones) comment := fmt.Sprintf("opsx-vpn-openvpn-%d", t.ID) candidates := [][]string{ {"-t", "nat", "-D", "POSTROUTING", "-s", subnet, "-o", egress, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}, {"-D", "FORWARD", "-i", "tun+", "-o", egress, "-s", subnet, "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, {"-D", "FORWARD", "-i", egress, "-o", "tun+", "-d", subnet, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, // Rules produced by releases before interface-scoped forwarding. {"-D", "FORWARD", "-s", subnet, "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, {"-D", "FORWARD", "-d", subnet, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}, } for _, rule := range candidates { for { check := append([]string{}, rule...) for i := 0; i < len(check); i++ { if check[i] == "-D" { check[i] = "-C" break } } if _, err := service.Run(iptables, check...); err != nil { break } if _, err := service.Run(iptables, rule...); err != nil { return err } } } return nil } 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 st.TxBytes = t.TxBytes + tx return st, nil } if _, ok := findOpenVPNBin(); !ok { return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 openvpn,尚未部署"), nil } return service.NowStatus(false, "stopped", "未启动"), nil } type trafficSnapshot map[string]trafficCounter type trafficCounter struct { Rx int64 `json:"rx"` Tx int64 `json:"tx"` } func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) { raw, err := os.ReadFile(filepath.Join(service.InstanceDir(t), "openvpn-status.log")) if err != nil { return 0, 0 } snap := trafficSnapshot{} if b, e := os.ReadFile(filepath.Join(service.InstanceDir(t), "traffic-state.json")); e == nil { _ = json.Unmarshal(b, &snap) } next := trafficSnapshot{} var totalRx, totalTx int64 now := time.Now() // 收集在线客户端信息(供 sessionmgr 同步会话) var onlineClients []sessionmgr.ClientInfo for _, line := range strings.Split(string(raw), "\n") { f := splitStatusFields(line) if len(f) < 7 || f[0] != "CLIENT_LIST" { continue } rx, e1 := strconv.ParseInt(f[5], 10, 64) tx, e2 := strconv.ParseInt(f[6], 10, 64) if e1 != nil || e2 != nil { continue } cn := f[1] username := cn if len(f) > 9 && f[9] != "" && f[9] != "UNDEF" { username = f[9] } realAddr := f[2] virtualAddr := "" if len(f) > 4 { virtualAddr = f[3] } key := username + "|" + realAddr old := snap[key] deltaRx, deltaTx := rx-old.Rx, tx-old.Tx if deltaRx < 0 { deltaRx = rx } if deltaTx < 0 { deltaTx = tx } totalRx += deltaRx totalTx += deltaTx next[key] = trafficCounter{rx, tx} if store.DB != nil && username != "" { store.DB.Model(&model.VPNUser{}).Where("tunnel_id = ? AND (username = ? OR certificate = ?)", t.ID, username, cn).Updates(map[string]interface{}{"rx_bytes": gorm.Expr("rx_bytes + ?", deltaRx), "tx_bytes": gorm.Expr("tx_bytes + ?", deltaTx), "last_seen_at": now}) } // 收集在线客户端 onlineClients = append(onlineClients, sessionmgr.ClientInfo{ Username: username, RealAddress: realAddr, VirtualAddress: virtualAddr, RxBytes: rx, TxBytes: tx, }) } b, _ := json.Marshal(next) _ = os.WriteFile(filepath.Join(service.InstanceDir(t), "traffic-state.json"), b, 0o600) // 同步在线会话到 VPNSession 表 sessionmgr.SyncSessions(t.ID, "openvpn", onlineClients) return totalRx, totalTx } // splitStatusFields supports OpenVPN status-version 3 (tab-separated) and // legacy comma-separated status files. func splitStatusFields(line string) []string { if strings.Contains(line, "\t") { return strings.Split(line, "\t") } return strings.Split(line, ",") } func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) } // RunAuthHelper is called by the same opsx-vpn binary from OpenVPN's // auth-user-pass-verify hook. It returns 0 only for enabled, tunnel-scoped // local users or LDAP users whose recorded LDAP DN accepts the password. // MFA 支持:static-challenge(SCRV1 / 第三行)、或密码末尾附加 6 位 TOTP。 func RunAuthHelper(args []string) error { fs := flag.NewFlagSet("openvpn-auth", flag.ContinueOnError) fs.SetOutput(os.Stderr) tunnel := fs.Uint("tunnel", 0, "tunnel id") credentials := fs.String("credentials", "", "credentials file") if err := fs.Parse(args); err != nil { return err } if *tunnel == 0 || *credentials == "" { return errors.New("--tunnel 和 --credentials 必填") } raw, err := os.ReadFile(*credentials) if err != nil { return err } lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") if len(lines) < 2 || strings.TrimSpace(lines[0]) == "" { return errors.New("认证凭据格式无效") } username := strings.TrimSpace(lines[0]) passwordField := strings.TrimSpace(lines[1]) var challenge string if len(lines) >= 3 { challenge = strings.TrimSpace(lines[2]) } return Authenticate(uint(*tunnel), username, passwordField, challenge) } // Authenticate validates dial-in credentials for one OpenVPN tunnel. // password_is_cr 模式:密码与 OTP 分离(SCRV1 / static-challenge 响应格式); // 否则支持 password+OTP 拼接;response 可为 PIN+OTP。 func Authenticate(tunnelID uint, username, passwordField, challenge string) error { if passwordField == "" { return errors.New("密码为空") } var tunnel model.Tunnel if err := store.DB.First(&tunnel, tunnelID).Error; err != nil { return errors.New("隧道不存在") } cfg, _ := store.TunnelConfig(&tunnel) mfaMode := service.StrOr(cfg, "mfa_mode", "off") if service.Bool(cfg, "require_mfa", false) && mfaMode == "off" { mfaMode = "required" } // 未启用 MFA 时不要把密码末尾 6 位当成 OTP 截断,避免误伤含数字后缀的密码。 password, response := passwordField, "" if mfaMode == "optional" || mfaMode == "required" { password, response = splitPasswordAndOTP(passwordField, challenge) } else if decoded := decodeSCRV1(passwordField); decoded.ok { password = decoded.password } var user model.VPNUser if err := store.DB.Where("tunnel_id = ? AND username = ? AND enabled = ?", tunnelID, username, true).First(&user).Error; err != nil { return errors.New("拨号用户不存在或已禁用") } if err := verifyDialPassword(&user, password); err != nil { return err } return verifyDialMFA(&user, cfg, mfaMode, response) } func verifyDialPassword(user *model.VPNUser, password string) error { if user.Source == "local" { if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil { return errors.New("密码错误") } return nil } if user.Source != "ldap" || user.LDAPDN == "" { return errors.New("拨号用户认证来源无效") } var cfg model.LDAPConfig if err := store.DB.Where("enabled = ?", true).First(&cfg).Error; err != nil { return errors.New("LDAP 未启用") } dialURL, err := ldaputil.DialURL(cfg.URL) if err != nil { return err } conn, err := ldap.DialURL(dialURL) if err != nil { return err } defer conn.Close() if cfg.BindDN != "" { bind, err := store.DecryptSecret(cfg.BindPassword) if err != nil { return errors.New("LDAP 绑定密钥无效") } if err := conn.Bind(cfg.BindDN, bind); err != nil { return err } } if err := conn.Bind(user.LDAPDN, password); err != nil { return errors.New("LDAP 密码错误") } return nil } var mfaAuthMu sync.Mutex func verifyDialMFA(user *model.VPNUser, tunnelCfg map[string]interface{}, mfaMode, response string) error { switch mfaMode { case "", "off": return nil case "optional": if !user.MFAEnabled { return nil } case "required": if !user.MFAEnabled { return errors.New("该隧道要求 MFA,请先在管理端绑定 Authenticator") } default: return nil } mfaAuthMu.Lock() defer mfaAuthMu.Unlock() // 重新加载用户以拿到最新锁定/计数器状态。 if store.DB != nil { _ = store.DB.First(user, user.ID).Error } if user.MFALockedUntil != nil && time.Now().Before(*user.MFALockedUntil) { return fmt.Errorf("MFA 已锁定至 %s,请稍后再试或联系管理员解锁", user.MFALockedUntil.Local().Format("15:04:05")) } response = strings.TrimSpace(response) if response == "" { recordMFAFailure(user, tunnelCfg) return errors.New("需要 MFA 验证码(Google Authenticator 6 位,或 PIN+OTP)") } // 1) 备用码(可含 PIN 前缀:PIN+BACKUP) otpPart, pinErr := extractOTPAfterPIN(user, response) if pinErr != nil { recordMFAFailure(user, tunnelCfg) return pinErr } if consumeBackupCode(user, otpPart) { clearMFAFailure(user) now := time.Now() user.MFALastUsedAt = &now _ = store.DB.Model(user).Updates(map[string]interface{}{ "mfa_fail_count": 0, "mfa_locked_until": nil, "mfa_last_used_at": now, }).Error return nil } // 2) TOTP 校验(sha1/base32/6/30s + slop 时钟偏差窗口) if user.MFASecret == "" { recordMFAFailure(user, tunnelCfg) return errors.New("MFA 密钥无效") } secret, err := store.DecryptSecret(user.MFASecret) if err != nil || secret == "" { recordMFAFailure(user, tunnelCfg) return errors.New("MFA 密钥无效") } slop := int(service.Num(tunnelCfg, "otp_slop", otpDefaultSlop)) if slop < 0 { slop = 0 } skew := uint(slop / otpPeriod) if skew < 1 && slop > 0 { skew = 1 } ok, counter := validateTOTPSkew(otpPart, secret, skew) if !ok { recordMFAFailure(user, tunnelCfg) return errors.New("MFA 验证码错误") } if service.Bool(tunnelCfg, "otp_disallow_reuse", true) && user.MFALastCounter != 0 && counter == user.MFALastCounter { recordMFAFailure(user, tunnelCfg) return errors.New("该验证码已使用,请等待下一个 30 秒周期") } now := time.Now() user.MFALastCounter = counter user.MFALastUsedAt = &now user.MFAFailCount = 0 user.MFALockedUntil = nil _ = store.DB.Model(user).Updates(map[string]interface{}{ "mfa_last_counter": counter, "mfa_last_used_at": now, "mfa_fail_count": 0, "mfa_locked_until": nil, }).Error return nil } // extractOTPAfterPIN 从响应中提取 OTP:response = [pin][otp] 拼接格式。 // 若用户设置了 PIN,则必须作为前缀;OTP 为剩余部分(6 位或备用码)。 func extractOTPAfterPIN(user *model.VPNUser, response string) (string, error) { if !user.MFAHasPin || user.MFAPin == "" { return response, nil } pin, err := store.DecryptSecret(user.MFAPin) if err != nil || pin == "" { return "", errors.New("MFA PIN 配置无效") } if !strings.HasPrefix(response, pin) { return "", errors.New("MFA PIN 错误") } otp := strings.TrimPrefix(response, pin) if otp == "" { return "", errors.New("请在 PIN 后输入 6 位动态码") } return otp, nil } func validateTOTPSkew(code, secret string, skew uint) (bool, int64) { code = strings.TrimSpace(code) if !isSixDigits(code) { return false, 0 } now := time.Now() opts := totp.ValidateOpts{ Period: otpPeriod, Skew: skew, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1, } ok, err := totp.ValidateCustom(code, secret, now, opts) if err != nil || !ok { return false, 0 } // 定位实际命中的时间窗计数器(用于防重放)。 period := int64(otpPeriod) base := now.Unix() / period for i := -int(skew); i <= int(skew); i++ { c := base + int64(i) t := time.Unix(c*period, 0) if valid, _ := totp.ValidateCustom(code, secret, t, totp.ValidateOpts{ Period: otpPeriod, Skew: 0, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1, }); valid { return true, c } } return true, base } func recordMFAFailure(user *model.VPNUser, tunnelCfg map[string]interface{}) { if store.DB == nil || user == nil { return } maxFail := int(service.Num(tunnelCfg, "otp_max_failures", 5)) lockSec := int(service.Num(tunnelCfg, "otp_lockout_sec", 300)) user.MFAFailCount++ updates := map[string]interface{}{"mfa_fail_count": user.MFAFailCount} if maxFail > 0 && user.MFAFailCount >= maxFail && lockSec > 0 { until := time.Now().Add(time.Duration(lockSec) * time.Second) user.MFALockedUntil = &until updates["mfa_locked_until"] = until } _ = store.DB.Model(user).Updates(updates).Error } func clearMFAFailure(user *model.VPNUser) { user.MFAFailCount = 0 user.MFALockedUntil = nil } // splitPasswordAndOTP 解析 OpenVPN 密码字段中的 MFA。 // password_is_cr 模式:SCRV1 / 第三行 challenge;否则密码后缀 6 位 OTP。 func splitPasswordAndOTP(passwordField, challenge string) (password, response string) { if decoded := decodeSCRV1(passwordField); decoded.ok { return decoded.password, decoded.otp } if challenge != "" { return passwordField, challenge } // 无 challenge:密码 + 6 位 OTP 直接拼接 if len(passwordField) > 6 { suffix := passwordField[len(passwordField)-6:] if isSixDigits(suffix) { return passwordField[:len(passwordField)-6], suffix } } return passwordField, "" } type scrv1 struct { password, otp string ok bool } func decodeSCRV1(raw string) scrv1 { // SCRV1:: if !strings.HasPrefix(raw, "SCRV1:") { return scrv1{} } parts := strings.SplitN(raw, ":", 3) if len(parts) != 3 { return scrv1{} } pw, err1 := base64.StdEncoding.DecodeString(parts[1]) otp, err2 := base64.StdEncoding.DecodeString(parts[2]) if err1 != nil || err2 != nil { pw, err1 = base64.RawStdEncoding.DecodeString(parts[1]) otp, err2 = base64.RawStdEncoding.DecodeString(parts[2]) if err1 != nil || err2 != nil { return scrv1{} } } return scrv1{password: string(pw), otp: string(otp), ok: true} } func isSixDigits(s string) bool { if len(s) != 6 { return false } for _, r := range s { if r < '0' || r > '9' { return false } } return true } func consumeBackupCode(user *model.VPNUser, code string) bool { if user.MFABackupCodes == "" || code == "" { return false } enc, err := store.DecryptSecret(user.MFABackupCodes) if err != nil { return false } var codes []string if json.Unmarshal([]byte(enc), &codes) != nil { return false } want := strings.ToUpper(strings.TrimSpace(code)) next := make([]string, 0, len(codes)) found := false for _, c := range codes { if !found && strings.ToUpper(strings.TrimSpace(c)) == want { found = true continue } next = append(next, c) } if !found { return false } raw, _ := json.Marshal(next) encNext, err := store.EncryptSecret(string(raw)) if err != nil { return false } user.MFABackupCodes = encNext _ = store.DB.Model(user).Update("mfa_backup_codes", encNext).Error return true } // GenerateOTPSecretsLine 导出标准 otp-secrets 格式密钥行(用于备份与迁移)。 // 格式: user server type:hash:encoding:key:pin:udid client func GenerateOTPSecretsLine(user *model.VPNUser) (string, error) { if !user.MFAEnabled || user.MFASecret == "" { return "", errors.New("用户未启用 MFA") } secret, err := store.DecryptSecret(user.MFASecret) if err != nil { return "", err } pin := "" if user.MFAHasPin && user.MFAPin != "" { pin, _ = store.DecryptSecret(user.MFAPin) } // totp:sha1:base32:KEY:PIN:xxx return fmt.Sprintf("%s otp totp:sha1:base32:%s:%s:xxx *", user.Username, secret, pin), nil } // SetUserMFAPin 设置或清除静态 PIN(连接时第二因子 PIN+OTP)。 func SetUserMFAPin(user *model.VPNUser, pin string) error { if pin == "" { user.MFAPin = "" user.MFAHasPin = false return store.DB.Model(user).Updates(map[string]interface{}{"mfa_pin": "", "mfa_has_pin": false}).Error } if len(pin) < 4 || len(pin) > 32 { return errors.New("PIN 长度需 4-32 位") } if strings.ContainsAny(pin, " \t\r\n") { return errors.New("PIN 不能包含空白字符") } enc, err := store.EncryptSecret(pin) if err != nil { return err } user.MFAPin = enc user.MFAHasPin = true return store.DB.Model(user).Updates(map[string]interface{}{"mfa_pin": enc, "mfa_has_pin": true}).Error } // UnlockUserMFA 清除失败计数与锁定(OTP Manager)。 func UnlockUserMFA(user *model.VPNUser) error { user.MFAFailCount = 0 user.MFALockedUntil = nil return store.DB.Model(user).Updates(map[string]interface{}{ "mfa_fail_count": 0, "mfa_locked_until": nil, }).Error } // RegenerateBackupCodes 重新生成一次性备用码。 func RegenerateBackupCodes(user *model.VPNUser) ([]string, error) { if !user.MFAEnabled { return nil, errors.New("请先启用 MFA") } codes := make([]string, 10) for i := range codes { raw := make([]byte, 5) if _, err := rand.Read(raw); err != nil { return nil, err } codes[i] = fmt.Sprintf("%X", raw) } raw, _ := json.Marshal(codes) enc, err := store.EncryptSecret(string(raw)) if err != nil { return nil, err } user.MFABackupCodes = enc if err := store.DB.Model(user).Update("mfa_backup_codes", enc).Error; err != nil { return nil, err } return codes, nil }