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

1488 lines
50 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package openvpn 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" {
if raw, present := out["feishu_access_mode"]; present {
if _, ok := raw.(string); !ok {
return nil, fmt.Errorf("feishu_access_mode 必须为字符串")
}
}
mode := service.Str(out, "feishu_access_mode")
if mode != "" && mode != "all" && mode != "allowlist" {
return nil, fmt.Errorf("feishu_access_mode 必须为 all 或 allowlist")
}
if mode == "allowlist" && service.Str(out, "oidc_provider") != "builtin" {
return nil, fmt.Errorf("指定飞书成员仅适用于内置飞书身份服务")
}
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 {
delete(out, "feishu_access_mode")
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("<ca>\n" + ca + "</ca>\n")
if cert != "" {
b.WriteString("<cert>\n" + cert + "</cert>\n<key>\n" + key + "</key>\n")
}
b.WriteString("<tls-crypt>\n" + ta + "</tls-crypt>\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:<base64 password>:<base64 response>
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
}