1258 lines
41 KiB
Go
1258 lines
41 KiB
Go
// 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"
|
||
"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
|
||
)
|
||
|
||
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"] = "udp"
|
||
}
|
||
// 前端/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"] = "certificate"
|
||
}
|
||
switch service.Str(out, "auth_mode") {
|
||
case "certificate", "password", "certificate+password":
|
||
default:
|
||
return nil, fmt.Errorf("auth_mode 必须为 certificate、password 或 certificate+password")
|
||
}
|
||
// 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 service.Str(out, "push_routes") != "" {
|
||
for _, r := range strings.Split(service.Str(out, "push_routes"), ",") {
|
||
r = strings.TrimSpace(r)
|
||
if r == "" {
|
||
continue
|
||
}
|
||
if _, _, err := net.ParseCIDR(r); err != nil {
|
||
return nil, fmt.Errorf("push_routes 含非法 CIDR: %s", r)
|
||
}
|
||
}
|
||
}
|
||
if _, ok := out["redirect_gateway"]; !ok {
|
||
// 未填 push_routes 时默认全流量;有 push_routes 时默认仅推内网。
|
||
out["redirect_gateway"] = service.Str(out, "push_routes") == ""
|
||
}
|
||
if _, ok := out["pki_auto_init"]; !ok {
|
||
out["pki_auto_init"] = true
|
||
}
|
||
if service.Bool(out, "enable_nat", false) && 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"
|
||
}
|
||
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 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 _, 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)
|
||
public, secrets := service.MergeSecrets(cfg)
|
||
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\nifconfig-pool-persist %q\n", service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0"), filepath.Join(dir, "ipp.txt"))
|
||
// 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")
|
||
}
|
||
for _, r := range strings.Split(service.Str(cfg, "push_routes"), ",") {
|
||
r = strings.TrimSpace(r)
|
||
if r == "" {
|
||
continue
|
||
}
|
||
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\npersist-key\npersist-tun\n", 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")
|
||
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
|
||
}
|
||
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 _, p := range []string{"/usr/share/easy-rsa/easyrsa", "/usr/local/share/easy-rsa/easyrsa"} {
|
||
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", "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())
|
||
return "#!/bin/sh\nset -eu\ncd " + shellQuote(service.InstanceDir(t)) + "\nexport EASYRSA_BATCH=1 EASYRSA_REQ_CN=OPSX-VPN-CA\n" + easy + " init-pki\n" + easy + " --batch build-ca nopass\n" + easy + " --batch build-server-full server nopass\n" + easy + " gen-dh\n" + 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" {
|
||
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" {
|
||
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
|
||
fmt.Fprintf(&b, "client\ndev tun\nproto %s\nremote %s %d\nresolv-retry infinite\nnobind\npersist-key\npersist-tun\nremote-cert-tls server\ndata-ciphers %s:AES-128-GCM\nverb 3\n", clientProto(service.StrOr(cfg, "proto", "udp")), service.StrOr(cfg, "public_endpoint", "your-server-ip"), int(service.Num(cfg, "port", 1194)), service.StrOr(cfg, "cipher", "AES-256-GCM"))
|
||
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 == "" {
|
||
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 {
|
||
if _, ok := findOpenVPNBin(); !ok {
|
||
return fmt.Errorf("无法启动 OpenVPN:未检测到 openvpn")
|
||
}
|
||
if err := pkiReady(t); err != nil {
|
||
return fmt.Errorf("无法启动 OpenVPN:PKI 尚未就绪:%w", err)
|
||
}
|
||
if err := service.RequireRoot("OpenVPN"); err != nil {
|
||
return err
|
||
}
|
||
if err := service.RequireIPv4Forwarding("OpenVPN"); err != nil {
|
||
return err
|
||
}
|
||
cfg, err := store.TunnelConfig(t)
|
||
if 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", false) {
|
||
if err := s.installNAT(t, cfg); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := service.StartDaemon(t, serviceMustBin("openvpn"), "--config", filepath.Join(service.InstanceDir(t), "server.conf")); err != nil {
|
||
_ = s.removeNAT(t, cfg)
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
func serviceMustBin(name string) string { p, _ := service.LookBin(name); return p }
|
||
func (s *Service) Stop(t *model.Tunnel) error {
|
||
var firstErr error
|
||
if err := service.KillPid(t); err != nil {
|
||
firstErr = err
|
||
}
|
||
if cfg, err := store.TunnelConfig(t); err == nil && service.Bool(cfg, "enable_nat", false) {
|
||
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", "-s", subnet, "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||
{"-A", "FORWARD", "-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", "-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 {
|
||
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 := strings.Split(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
|
||
}
|
||
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
|
||
}
|