// 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" {
switch service.Str(out, "oidc_provider") {
case "", "external", "builtin":
default:
return nil, fmt.Errorf("oidc_provider 必须为内置飞书或外部 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_provider", "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 := statusUsername(f)
realAddr := f[2]
virtualAddr := ""
if len(f) > 4 {
virtualAddr = f[3]
}
keyName := username
if keyName == "" {
// Preserve the old traffic snapshot key for anonymous OIDC peers.
keyName = cn
}
key := keyName + "|" + realAddr
old, found := snap[key]
if !found && username != "" {
// A WebAuth peer can gain a verified username after its initial
// UNDEF snapshot. Keep counting from the same socket's prior value.
old = snap["UNDEF|"+realAddr]
}
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, ",")
}
// OpenVPN writes UNDEF when certificate-free WebAuth has not supplied a
// username. It is a protocol placeholder, never a verified Feishu identity.
func statusUsername(fields []string) string {
if len(fields) > 9 {
if username := strings.TrimSpace(fields[9]); username != "" && !strings.EqualFold(username, "UNDEF") {
return username
}
}
if len(fields) > 1 {
if cn := strings.TrimSpace(fields[1]); cn != "" && !strings.EqualFold(cn, "UNDEF") {
return cn
}
}
return ""
}
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
}