feat: 全面优化 — UI/UX重构 + 流量审计与会话管理 + 协议部署
UI/UX 优化: - 登录页分屏布局 + 可访问性修复 + 内联错误提示 - 侧栏菜单分组(专线运维/资源管理/系统) + 图标修正 + 子路由高亮 - Dashboard 统计卡渐变图标 + 空状态引导 + 统计卡可点击 - 创建向导: 依赖检测门控 + 高级折叠 + 必填校验 + WireGuard推荐标 - 专线列表: 空状态CTA + 操作列「更多」下拉 + 状态筛选 + SSE日志流 - 协议品牌SVG图标(ProtocolIcon组件)替换emoji - 设计令牌系统(tokens.css)统一颜色/间距/圆角/阴影 功能增强: - 在线会话管理(sessionmgr): 跨协议会话同步 + 踢下线 + 连接审计日志 - 流量配额引擎(trafficmgr): 配额检查 + 超额动作(block/warn/throttle) + 周期重置 - 异常流量告警: 突发流量检测(>90MB/15s) + 审计记录 - 节点SSH凭证管理: 密码/私钥认证 + 即时测试连接 + AES-GCM加密存储 - 协议二进制管理(binmgr): FRP/OpenVPN GitHub下载 + 系统包安装引导(apt/yum/dnf) - 右上角修改密码弹窗 + MFA独立验证页 + 密码强度提示 - 用户管理: 批量导入 + LDAP浏览导入 + 平台用户导入VPN拨号用户 - 404路由 + IPSec对接页可达性修复 - MFA备用码持久化弹窗(替代Toast) - 轮询可见性守卫 + ECharts resize修复 后端: - 3张新表: VPNSession/ConnectionLog/TrafficPolicy - monitor增强: 会话同步+配额检查+异常告警+到期审计 - changePassword统一8位+新旧不同校验 - openvpn-otp文案专业化(移除抄袭感) - SSE日志流(token降级query参数)
This commit is contained in:
1 parent
58c036c34c
commit
1e2f8ef24f
43 files changed
+6783
-460
No files matched your search
@@ -164,22 +164,42 @@ func ReadPid(t *model.Tunnel) (int, bool) {
|
||||
return pid, true
|
||||
}
|
||||
|
||||
// KillPid 停止实例进程
|
||||
// KillPid 优雅停止实例进程:先 SIGTERM,超时后再 SIGKILL。
|
||||
func KillPid(t *model.Tunnel) error {
|
||||
pid, alive := ReadPid(t)
|
||||
if !alive {
|
||||
_ = os.Remove(PidFile(t))
|
||||
return nil
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
_ = os.Remove(PidFile(t))
|
||||
return nil
|
||||
}
|
||||
_ = proc.Signal(syscall.SIGTERM)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
||||
_ = os.Remove(PidFile(t))
|
||||
return nil
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
_ = proc.Kill()
|
||||
_, _ = proc.Wait()
|
||||
_ = os.Remove(PidFile(t))
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireTCPPortAvailable 启动前检查 TCP 端口是否空闲。
|
||||
func RequireTCPPortAvailable(port int, component string) error {
|
||||
ln, err := net.Listen("tcp4", fmt.Sprintf(":%d", port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法启动 %s:TCP %d 已被其他服务占用: %w", component, port, err)
|
||||
}
|
||||
return ln.Close()
|
||||
}
|
||||
|
||||
// WriteConfig 写入实例配置文件(0600 权限)
|
||||
func WriteConfig(t *model.Tunnel, name, content string) (string, error) {
|
||||
if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil {
|
||||
|
||||
@@ -3,6 +3,8 @@ package frp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -14,6 +16,17 @@ import (
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// findBin 查找 FRP 二进制路径:优先 data/bin/(平台下载),其次 PATH(系统安装)
|
||||
func findBin(name string) (string, bool) {
|
||||
// 1. data/bin/(平台下载部署的)
|
||||
managed := filepath.Join(service.BaseDir(), "bin", name)
|
||||
if fi, err := os.Stat(managed); err == nil && !fi.IsDir() {
|
||||
return managed, true
|
||||
}
|
||||
// 2. 系统 PATH
|
||||
return service.LookBin(name)
|
||||
}
|
||||
|
||||
type Service struct{}
|
||||
|
||||
// Proxy is the supported, repeatable FRP proxy rule. It is deliberately kept
|
||||
@@ -29,6 +42,7 @@ type Proxy struct {
|
||||
Subdomain string `json:"subdomain"`
|
||||
Locations []string `json:"locations"`
|
||||
HostHeaderRewrite string `json:"host_header_rewrite"`
|
||||
SecretKey string `json:"secret_key,omitempty"` // stcp/xtcp 访问密钥
|
||||
}
|
||||
|
||||
var safeName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
|
||||
@@ -38,10 +52,13 @@ func (s *Service) Name() string { return "frp" }
|
||||
func (s *Service) DisplayName() string { return "FRP 内网穿透" }
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if p, ok := service.LookBin("frps", "frpc"); ok {
|
||||
if p, ok := findBin("frps"); ok {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 frps/frpc 二进制,可生成配置但无法启动"
|
||||
if p, ok := findBin("frpc"); ok {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 frps/frpc 二进制,点击「下载部署」可自动从 GitHub 下载"
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
@@ -106,8 +123,10 @@ func validateProxy(p Proxy) error {
|
||||
if !safeName.MatchString(p.Name) {
|
||||
return fmt.Errorf("代理名称 %q 无效", p.Name)
|
||||
}
|
||||
if p.Type != "tcp" && p.Type != "udp" && p.Type != "http" && p.Type != "https" {
|
||||
return fmt.Errorf("代理 %s 的类型必须为 tcp、udp、http 或 https", p.Name)
|
||||
switch p.Type {
|
||||
case "tcp", "udp", "http", "https", "stcp", "xtcp":
|
||||
default:
|
||||
return fmt.Errorf("代理 %s 的类型必须为 tcp、udp、http、https、stcp 或 xtcp", p.Name)
|
||||
}
|
||||
if p.LocalPort < 1 || p.LocalPort > 65535 {
|
||||
return fmt.Errorf("代理 %s 的 local_port 无效", p.Name)
|
||||
@@ -124,6 +143,15 @@ func validateProxy(p Proxy) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if p.Type == "stcp" || p.Type == "xtcp" {
|
||||
if p.SecretKey == "" {
|
||||
return fmt.Errorf("STCP/XTCP 代理 %s 必须填写 secret_key", p.Name)
|
||||
}
|
||||
if strings.ContainsAny(p.SecretKey, "\r\n\"'") {
|
||||
return fmt.Errorf("代理 %s 的 secret_key 非法", p.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(p.CustomDomains) == 0 && p.Subdomain == "" {
|
||||
return fmt.Errorf("HTTP/HTTPS 代理 %s 必须填写 custom_domains 或 subdomain", p.Name)
|
||||
}
|
||||
@@ -218,9 +246,12 @@ func (s *Service) frpcConfig(cfg map[string]interface{}, token string) string {
|
||||
rules, _ := proxyRules(cfg)
|
||||
for _, p := range rules {
|
||||
fmt.Fprintf(&b, "\n[[proxies]]\nname = %q\ntype = %q\nlocalIP = %q\nlocalPort = %d\n", p.Name, p.Type, defaultString(p.LocalIP, "127.0.0.1"), p.LocalPort)
|
||||
if p.Type == "tcp" || p.Type == "udp" {
|
||||
switch p.Type {
|
||||
case "tcp", "udp":
|
||||
fmt.Fprintf(&b, "remotePort = %d\n", p.RemotePort)
|
||||
} else {
|
||||
case "stcp", "xtcp":
|
||||
fmt.Fprintf(&b, "secretKey = %q\n", p.SecretKey)
|
||||
default:
|
||||
if len(p.CustomDomains) > 0 {
|
||||
fmt.Fprintf(&b, "customDomains = [%s]\n", strings.Join(quoteStrings(p.CustomDomains), ", "))
|
||||
}
|
||||
@@ -262,10 +293,10 @@ func (s *Service) Start(t *model.Tunnel) error {
|
||||
if service.StrOr(cfg, "mode", "server") == "server" {
|
||||
bin, conf = "frps", "frps.toml"
|
||||
}
|
||||
if p, ok := service.LookBin(bin); ok {
|
||||
if p, ok := findBin(bin); ok {
|
||||
return service.StartDaemon(t, p, "-c", fmt.Sprintf("%s/%s", service.InstanceDir(t), conf))
|
||||
}
|
||||
return fmt.Errorf("无法启动 FRP:未检测到 %s", bin)
|
||||
return fmt.Errorf("无法启动 FRP:未检测到 %s,点击「下载部署」自动安装", bin)
|
||||
}
|
||||
func (s *Service) Stop(t *model.Tunnel) error { return service.KillPid(t) }
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
@@ -277,7 +308,7 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if service.StrOr(cfg, "mode", "server") == "server" {
|
||||
bin = "frps"
|
||||
}
|
||||
if _, ok := service.LookBin(bin); !ok {
|
||||
if _, ok := findBin(bin); !ok {
|
||||
return service.NowStatus(false, "undeployed", fmt.Sprintf("配置已生成,但未检测到 %s,尚未部署", bin)), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "未启动"), nil
|
||||
@@ -296,7 +327,7 @@ func proxyRules(cfg map[string]interface{}) ([]Proxy, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("proxies 中每项必须是对象")
|
||||
}
|
||||
p := Proxy{Name: service.Str(m, "name"), Type: service.StrOr(m, "type", "tcp"), LocalIP: service.StrOr(m, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(m, "local_port", 0)), RemotePort: int(service.Num(m, "remote_port", 0)), Subdomain: service.Str(m, "subdomain"), HostHeaderRewrite: service.Str(m, "host_header_rewrite")}
|
||||
p := Proxy{Name: service.Str(m, "name"), Type: service.StrOr(m, "type", "tcp"), LocalIP: service.StrOr(m, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(m, "local_port", 0)), RemotePort: int(service.Num(m, "remote_port", 0)), Subdomain: service.Str(m, "subdomain"), HostHeaderRewrite: service.Str(m, "host_header_rewrite"), SecretKey: service.Str(m, "secret_key")}
|
||||
p.CustomDomains = stringList(m["custom_domains"])
|
||||
p.Locations = stringList(m["locations"])
|
||||
rules = append(rules, p)
|
||||
@@ -304,14 +335,14 @@ func proxyRules(cfg map[string]interface{}) ([]Proxy, error) {
|
||||
return rules, nil
|
||||
}
|
||||
if name := service.Str(cfg, "proxy_name"); name != "" {
|
||||
return []Proxy{{Name: name, Type: service.StrOr(cfg, "proxy_type", "tcp"), LocalIP: service.StrOr(cfg, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(cfg, "local_port", 0)), RemotePort: int(service.Num(cfg, "remote_port", 0)), CustomDomains: splitDomains(service.Str(cfg, "custom_domains"))}}, nil
|
||||
return []Proxy{{Name: name, Type: service.StrOr(cfg, "proxy_type", "tcp"), LocalIP: service.StrOr(cfg, "local_ip", "127.0.0.1"), LocalPort: int(service.Num(cfg, "local_port", 0)), RemotePort: int(service.Num(cfg, "remote_port", 0)), CustomDomains: splitDomains(service.Str(cfg, "custom_domains")), SecretKey: service.Str(cfg, "secret_key")}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func proxiesJSON(in []Proxy) []interface{} {
|
||||
out := make([]interface{}, 0, len(in))
|
||||
for _, p := range in {
|
||||
out = append(out, map[string]interface{}{"name": p.Name, "type": p.Type, "local_ip": p.LocalIP, "local_port": p.LocalPort, "remote_port": p.RemotePort, "custom_domains": p.CustomDomains, "subdomain": p.Subdomain, "locations": p.Locations, "host_header_rewrite": p.HostHeaderRewrite})
|
||||
out = append(out, map[string]interface{}{"name": p.Name, "type": p.Type, "local_ip": p.LocalIP, "local_port": p.LocalPort, "remote_port": p.RemotePort, "custom_domains": p.CustomDomains, "subdomain": p.Subdomain, "locations": p.Locations, "host_header_rewrite": p.HostHeaderRewrite, "secret_key": p.SecretKey})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
// Package ikev2 实现 IKEv2 远程接入(Road Warrior):员工手机/笔记本拨号进内网。
|
||||
// 依赖 strongSwan swanctl;认证为「服务器 PSK + 用户 EAP-MSCHAPv2」。
|
||||
// 拨号用户来自 VPNUser.DialSecret(可逆加密)与隧道级默认用户。
|
||||
package ikev2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func New() *Service { return &Service{} }
|
||||
func (s *Service) Name() string { return "ikev2" }
|
||||
func (s *Service) DisplayName() string { return "IKEv2 远程接入" }
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if p, ok := service.LookBin("swanctl"); ok {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 strongSwan(swanctl),可生成配置但无法启动"
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
if service.Str(out, "address_pool") == "" {
|
||||
out["address_pool"] = "10.12.0.0/24"
|
||||
}
|
||||
if _, err := netip.ParsePrefix(service.Str(out, "address_pool")); err != nil {
|
||||
return nil, fmt.Errorf("address_pool 必须是 CIDR,例如 10.12.0.0/24")
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
}
|
||||
if service.Str(out, "local_id") == "" {
|
||||
out["local_id"] = "@opsx-ikev2"
|
||||
}
|
||||
if service.Str(out, "ike_proposal") == "" {
|
||||
out["ike_proposal"] = "aes256-sha256-modp2048"
|
||||
}
|
||||
if service.Str(out, "esp_proposal") == "" {
|
||||
out["esp_proposal"] = "aes256-sha256"
|
||||
}
|
||||
if service.Str(out, "local_ts") == "" {
|
||||
out["local_ts"] = "0.0.0.0/0"
|
||||
}
|
||||
for _, key := range []string{"local_id", "ike_proposal", "esp_proposal", "local_ts"} {
|
||||
if strings.ContainsAny(service.Str(out, key), "\r\n{}\"") {
|
||||
return nil, fmt.Errorf("%s 包含配置文件不允许的字符", key)
|
||||
}
|
||||
}
|
||||
if service.Str(out, "username") != "" && strings.ContainsAny(service.Str(out, "username"), " \t\r\n\"") {
|
||||
return nil, fmt.Errorf("username 不能包含空白或引号")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(t *model.Tunnel) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
if psk := service.Str(cfg, "psk"); psk != "" {
|
||||
secrets["psk"] = psk
|
||||
} else if service.Str(secrets, "psk") == "" {
|
||||
psk, err := service.RandomSecret(24)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets["psk"] = psk
|
||||
}
|
||||
if pw := service.Str(cfg, "password"); pw != "" {
|
||||
secrets["password"] = pw
|
||||
}
|
||||
username := service.StrOr(cfg, "username", "vpnuser")
|
||||
dialUsers, err := loadDialUsers(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if service.Str(secrets, "password") == "" && len(dialUsers) == 0 {
|
||||
// 自动生成一个默认拨号密码,避免空库无法联调。
|
||||
pw, err := service.RandomSecret(12)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets["password"] = pw
|
||||
}
|
||||
|
||||
conf := s.swanctlConf(t.ID, cfg, service.Str(secrets, "psk"), username, service.Str(secrets, "password"), dialUsers)
|
||||
if _, err := service.WriteConfig(t, "swanctl-ikev2.conf", conf); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := service.WriteConfig(t, "client-guide.txt", s.clientGuide(cfg, secrets, username)); err != nil {
|
||||
return err
|
||||
}
|
||||
public, sec := service.MergeSecrets(cfg, "psk", "password")
|
||||
for k, v := range secrets {
|
||||
sec[k] = v
|
||||
}
|
||||
return service.SaveTunnelConfig(t, public, sec)
|
||||
}
|
||||
|
||||
// RefreshAuth rewrites EAP secrets after dial-user changes.
|
||||
func (s *Service) RefreshAuth(t *model.Tunnel) error { return s.Create(t) }
|
||||
|
||||
func (s *Service) swanctlConf(id uint, cfg map[string]interface{}, psk, defaultUser, defaultPass string, users []dialUser) string {
|
||||
var secrets strings.Builder
|
||||
fmt.Fprintf(&secrets, `secrets {
|
||||
ike-rw-%d {
|
||||
id = %s
|
||||
secret = %s
|
||||
}
|
||||
`, id, service.Str(cfg, "local_id"), strconv.Quote(psk))
|
||||
seen := map[string]bool{}
|
||||
if defaultUser != "" && defaultPass != "" {
|
||||
fmt.Fprintf(&secrets, ` eap-%s-%d {
|
||||
id = %s
|
||||
secret = %s
|
||||
}
|
||||
`, defaultUser, id, strconv.Quote(defaultUser), strconv.Quote(defaultPass))
|
||||
seen[defaultUser] = true
|
||||
}
|
||||
for i, u := range users {
|
||||
if u.Username == "" || u.Password == "" || seen[u.Username] {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&secrets, ` eap-user-%d-%d {
|
||||
id = %s
|
||||
secret = %s
|
||||
}
|
||||
`, id, i, strconv.Quote(u.Username), strconv.Quote(u.Password))
|
||||
seen[u.Username] = true
|
||||
}
|
||||
secrets.WriteString("}\n")
|
||||
|
||||
return fmt.Sprintf(`connections {
|
||||
ikev2-rw-%d {
|
||||
version = 2
|
||||
pools = pool-%d
|
||||
proposals = %s
|
||||
dpd_delay = 30s
|
||||
dpd_timeout = 120s
|
||||
local {
|
||||
auth = psk
|
||||
id = %s
|
||||
}
|
||||
remote {
|
||||
auth = eap-mschapv2
|
||||
eap_id = %%any
|
||||
}
|
||||
children {
|
||||
net-%d {
|
||||
local_ts = %s
|
||||
esp_proposals = %s
|
||||
dpd_action = clear
|
||||
start_action = none
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pools {
|
||||
pool-%d {
|
||||
addrs = %s
|
||||
dns = %s
|
||||
}
|
||||
}
|
||||
%s`,
|
||||
id,
|
||||
id,
|
||||
service.Str(cfg, "ike_proposal"),
|
||||
service.Str(cfg, "local_id"),
|
||||
id,
|
||||
service.Str(cfg, "local_ts"),
|
||||
service.Str(cfg, "esp_proposal"),
|
||||
id,
|
||||
service.Str(cfg, "address_pool"),
|
||||
service.StrOr(cfg, "dns", "223.5.5.5"),
|
||||
secrets.String(),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) clientGuide(cfg, secrets map[string]interface{}, username string) string {
|
||||
return fmt.Sprintf(`======== IKEv2 远程接入参数 ========
|
||||
|
||||
服务器地址 : %s
|
||||
VPN 类型 : IKEv2
|
||||
远程 ID / 服务器 ID : %s
|
||||
本地 ID : (可留空,或填用户名)
|
||||
机器认证 : 预共享密钥 (PSK)
|
||||
预共享密钥 : %s
|
||||
用户认证 : 用户名 + 密码(EAP-MSCHAPv2)
|
||||
默认用户名 : %s
|
||||
默认密码 : %s
|
||||
地址池 : %s
|
||||
DNS : %s
|
||||
|
||||
客户端配置指引:
|
||||
- iOS/macOS : 设置 → 通用 → VPN 与设备管理 → VPN → 添加 IKEv2
|
||||
服务器/远程 ID 填上表;认证用用户名密码;共享密钥填 PSK
|
||||
- Windows : 设置 → 网络和 Internet → VPN → 添加 IKEv2
|
||||
- Android : 系统 VPN 或 strongSwan 客户端,类型选 IKEv2 EAP
|
||||
- 更多账号 : 在平台「VPN 拨号用户」中为本隧道添加用户后重新生成配置
|
||||
|
||||
注意:修改拨号用户后需在专线列表对该隧道执行「停止 → 启动」或重新保存配置以刷新 EAP 密钥。
|
||||
`,
|
||||
service.StrOr(cfg, "public_endpoint", "<服务器公网 IP 或域名>"),
|
||||
service.Str(cfg, "local_id"),
|
||||
service.Str(secrets, "psk"),
|
||||
username,
|
||||
service.Str(secrets, "password"),
|
||||
service.Str(cfg, "address_pool"),
|
||||
service.StrOr(cfg, "dns", "223.5.5.5"),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
return s.clientGuide(cfg, secrets, service.StrOr(cfg, "username", "vpnuser")), nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
confPath := service.InstanceDir(t) + "/swanctl-ikev2.conf"
|
||||
bin, ok := service.LookBin("swanctl")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法启动 IKEv2:未检测到 swanctl,请先安装 strongSwan")
|
||||
}
|
||||
if err := service.RequireRoot("IKEv2"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.RequireIPv4Forwarding("IKEv2"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(confPath); err != nil {
|
||||
return fmt.Errorf("无法启动 IKEv2:配置文件不存在,请先重新生成配置: %w", err)
|
||||
}
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.installFirewall(t, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.unload(bin, confPath)
|
||||
if _, err := service.Run(bin, "--load-all", "--file", confPath); err != nil {
|
||||
_ = s.removeFirewall(t, cfg)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Stop(t *model.Tunnel) error {
|
||||
var firstErr error
|
||||
if bin, ok := service.LookBin("swanctl"); ok {
|
||||
_, _ = service.Run(bin, "--terminate", "--ike", fmt.Sprintf("ikev2-rw-%d", t.ID))
|
||||
if err := s.unload(bin, service.InstanceDir(t)+"/swanctl-ikev2.conf"); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if cfg, err := store.TunnelConfig(t); err == nil {
|
||||
if err := s.removeFirewall(t, cfg); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if bin, ok := service.LookBin("swanctl"); ok {
|
||||
// Road Warrior 多数时候无固定 SA;配置已加载即视为服务就绪。
|
||||
out, err := service.Run(bin, "--list-conns")
|
||||
name := fmt.Sprintf("ikev2-rw-%d", t.ID)
|
||||
if err != nil {
|
||||
// list-conns 失败时再查 SA
|
||||
if sas, e2 := service.Run(bin, "--list-sas"); e2 == nil && strings.Contains(sas, name) {
|
||||
return service.NowStatus(true, "running", "IKEv2 远程接入运行中(有已建立会话)"), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "无法确认 IKEv2 连接定义是否已加载"), nil
|
||||
}
|
||||
if strings.Contains(out, name) {
|
||||
detail := "IKEv2 远程接入已加载,等待客户端拨号"
|
||||
if sas, e2 := service.Run(bin, "--list-sas"); e2 == nil {
|
||||
// 粗略统计会话数
|
||||
n := strings.Count(sas, name)
|
||||
if n > 0 {
|
||||
detail = fmt.Sprintf("IKEv2 远程接入运行中,约 %d 个相关 SA", n)
|
||||
}
|
||||
}
|
||||
return service.NowStatus(true, "running", detail), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "IKEv2 连接定义未加载"), nil
|
||||
}
|
||||
return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 strongSwan(swanctl),尚未部署"), nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
func (s *Service) unload(bin, confPath string) error {
|
||||
_, _ = service.Run(bin, "--unload-conns", "--file", confPath)
|
||||
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
type dialUser struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func loadDialUsers(tunnelID uint) ([]dialUser, error) {
|
||||
if store.DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var users []model.VPNUser
|
||||
if err := store.DB.Where("tunnel_id = ? AND enabled = ? AND source = ?", tunnelID, true, "local").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dialUser, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.DialSecret == "" {
|
||||
continue
|
||||
}
|
||||
pw, err := store.DecryptSecret(u.DialSecret)
|
||||
if err != nil || pw == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, dialUser{Username: u.Username, Password: pw})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) installFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法启动 IKEv2:未检测到 iptables")
|
||||
}
|
||||
comment := fmt.Sprintf("opsx-vpn-ikev2-%d", t.ID)
|
||||
outbound := service.Str(cfg, "outbound_interface")
|
||||
if outbound == "" {
|
||||
var err error
|
||||
outbound, err = service.DefaultRouteInterface()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rules := []struct {
|
||||
table, chain string
|
||||
args []string
|
||||
}{
|
||||
{"filter", "INPUT", []string{"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "INPUT", []string{"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "INPUT", []string{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"nat", "POSTROUTING", []string{"-s", service.Str(cfg, "address_pool"), "-o", outbound, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}},
|
||||
}
|
||||
for _, rule := range rules {
|
||||
prefix := []string{}
|
||||
if rule.table != "filter" {
|
||||
prefix = append(prefix, "-t", rule.table)
|
||||
}
|
||||
check := append(append([]string{}, prefix...), "-C", rule.chain)
|
||||
check = append(check, rule.args...)
|
||||
if _, err := service.Run(iptables, check...); err == nil {
|
||||
continue
|
||||
}
|
||||
add := append(append([]string{}, prefix...), "-A", rule.chain)
|
||||
add = append(add, rule.args...)
|
||||
if _, err := service.Run(iptables, add...); err != nil {
|
||||
_ = s.removeFirewall(t, cfg)
|
||||
return fmt.Errorf("无法添加 IKEv2 防火墙规则: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) removeFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
comment := fmt.Sprintf("opsx-vpn-ikev2-%d", t.ID)
|
||||
outbound := service.Str(cfg, "outbound_interface")
|
||||
if outbound == "" {
|
||||
outbound, _ = service.DefaultRouteInterface()
|
||||
}
|
||||
rules := []struct {
|
||||
table, chain string
|
||||
args []string
|
||||
}{
|
||||
{"filter", "INPUT", []string{"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "INPUT", []string{"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "INPUT", []string{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"nat", "POSTROUTING", []string{"-s", service.Str(cfg, "address_pool"), "-o", outbound, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}},
|
||||
}
|
||||
for _, rule := range rules {
|
||||
prefix := []string{}
|
||||
if rule.table != "filter" {
|
||||
prefix = append(prefix, "-t", rule.table)
|
||||
}
|
||||
for {
|
||||
check := append(append([]string{}, prefix...), "-C", rule.chain)
|
||||
check = append(check, rule.args...)
|
||||
if _, err := service.Run(iptables, check...); err != nil {
|
||||
break
|
||||
}
|
||||
remove := append(append([]string{}, prefix...), "-D", rule.chain)
|
||||
remove = append(remove, rule.args...)
|
||||
if _, err := service.Run(iptables, remove...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package ikev2
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateDefaults(t *testing.T) {
|
||||
cfg, err := New().Validate(map[string]interface{}{
|
||||
"public_endpoint": "1.2.3.4",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg["address_pool"] != "10.12.0.0/24" || cfg["local_id"] != "@opsx-ikev2" {
|
||||
t.Fatalf("defaults: %#v", cfg)
|
||||
}
|
||||
if _, err := New().Validate(map[string]interface{}{"address_pool": "not-a-cidr"}); err == nil {
|
||||
t.Fatal("expected invalid pool error")
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,27 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
// 厂商模板优先填空字段,再回落到通用默认值。
|
||||
if key := service.Str(out, "vendor_template"); key != "" {
|
||||
for _, tmpl := range Templates() {
|
||||
if tmpl.Key != key {
|
||||
continue
|
||||
}
|
||||
if service.Str(out, "ike_version") == "" {
|
||||
out["ike_version"] = tmpl.IKEVersion
|
||||
}
|
||||
if service.Str(out, "ike_proposal") == "" {
|
||||
out["ike_proposal"] = tmpl.IKEProposal
|
||||
}
|
||||
if service.Str(out, "esp_proposal") == "" {
|
||||
out["esp_proposal"] = tmpl.ESPProposal
|
||||
}
|
||||
if service.Str(out, "dpd_action") == "" {
|
||||
out["dpd_action"] = tmpl.DPDAction
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if service.Str(out, "ike_version") == "" {
|
||||
out["ike_version"] = "ikev2"
|
||||
}
|
||||
@@ -77,6 +98,12 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
if service.Str(out, "remote_id") == "" {
|
||||
out["remote_id"] = "@vpn-remote"
|
||||
}
|
||||
if service.Num(out, "ike_lifetime", 0) == 0 {
|
||||
out["ike_lifetime"] = 86400
|
||||
}
|
||||
if service.Str(out, "pfs_group") == "" {
|
||||
out["pfs_group"] = "modp2048"
|
||||
}
|
||||
remoteGateway := service.Str(out, "remote_gateway")
|
||||
// StrongSwan accepts both literal addresses and DNS names. Keep DNS support
|
||||
// for peers with dynamic addresses, while rejecting syntax that can alter the
|
||||
@@ -347,18 +374,73 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if bin, ok := service.LookBin("swanctl"); ok {
|
||||
out, err := service.Run(bin, "--list-sas")
|
||||
child := fmt.Sprintf("site-%d", t.ID)
|
||||
if err == nil && strings.Contains(out, child) && strings.Contains(out, "INSTALLED") {
|
||||
st := service.NowStatus(true, "running", "IKE/Child SA 已建立")
|
||||
return st, nil
|
||||
}
|
||||
ike := fmt.Sprintf("tunnel-%d", t.ID)
|
||||
if err != nil {
|
||||
return service.NowStatus(false, "error", "无法读取 strongSwan SA 状态: "+err.Error()), nil
|
||||
}
|
||||
if strings.Contains(out, child) && strings.Contains(out, "INSTALLED") {
|
||||
detail := "IKE/Child SA 已建立"
|
||||
// 从 swanctl 输出中尽量提取对端地址与字节计数(格式因版本略有差异)。
|
||||
if peer := extractSwanPeer(out, ike); peer != "" {
|
||||
detail += ",对端 " + peer
|
||||
}
|
||||
if rx, tx, ok := extractSwanBytes(out, child); ok {
|
||||
st := service.NowStatus(true, "running", detail)
|
||||
st.RxBytes = rx
|
||||
st.TxBytes = tx
|
||||
return st, nil
|
||||
}
|
||||
return service.NowStatus(true, "running", detail), nil
|
||||
}
|
||||
if strings.Contains(out, ike) {
|
||||
return service.NowStatus(false, "stopped", "IKE SA 存在但 Child SA 未 INSTALLED"), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "IPSec Child SA 未建立"), nil
|
||||
}
|
||||
return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 strongSwan(swanctl),尚未部署"), nil
|
||||
}
|
||||
|
||||
func extractSwanPeer(out, ikeName string) string {
|
||||
// 典型行: tunnel-1: #1, ESTABLISHED, ... 1.2.3.4[... ]...5.6.7.8[...]
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if !strings.Contains(line, ikeName) {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
for _, f := range fields {
|
||||
if strings.Contains(f, "[") && strings.Count(f, ".") >= 3 {
|
||||
return strings.Split(f, "[")[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractSwanBytes(out, childName string) (rx, tx int64, ok bool) {
|
||||
// 寻找含 child 名与 bytes_i/bytes_o 的行
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if !strings.Contains(line, childName) {
|
||||
continue
|
||||
}
|
||||
// 兼容 "123 bytes_i, 456 bytes_o" 与 "bytes_i/bytes_o"
|
||||
parts := strings.Fields(line)
|
||||
for i, p := range parts {
|
||||
if strings.Contains(p, "bytes_i") && i > 0 {
|
||||
fmt.Sscanf(parts[i-1], "%d", &rx)
|
||||
ok = true
|
||||
}
|
||||
if strings.Contains(p, "bytes_o") && i > 0 {
|
||||
fmt.Sscanf(parts[i-1], "%d", &tx)
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
func (s *Service) unload(bin, confPath string) error {
|
||||
|
||||
@@ -106,8 +106,14 @@ func (s *Service) Create(t *model.Tunnel) error {
|
||||
username := service.StrOr(cfg, "username", "vpnuser")
|
||||
if pw := service.Str(cfg, "password"); pw != "" {
|
||||
secrets["password"] = pw
|
||||
} else if service.Str(secrets, "password") == "" {
|
||||
return fmt.Errorf("缺少拨号密码 password")
|
||||
}
|
||||
// 允许仅依赖拨号用户表(VPNUser)而无隧道级默认密码。
|
||||
dialUsers, err := loadDialUsers(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if service.Str(secrets, "password") == "" && len(dialUsers) == 0 {
|
||||
return fmt.Errorf("缺少拨号密码:请填写隧道默认 password,或在「VPN 拨号用户」中添加本隧道用户")
|
||||
}
|
||||
|
||||
// 1) IPSec 层(transport 模式,L2TP over IPSec)
|
||||
@@ -172,9 +178,7 @@ mru 1400
|
||||
require-mschap-v2
|
||||
`, service.Str(cfg, "dns"))
|
||||
|
||||
chapSecrets := fmt.Sprintf(`# client server secret IP addresses
|
||||
%s l2tpd %s *
|
||||
`, username, strconv.Quote(service.Str(secrets, "password")))
|
||||
chapSecrets := buildChapSecrets(username, service.Str(secrets, "password"), dialUsers)
|
||||
|
||||
for name, content := range map[string]string{
|
||||
"swanctl-l2tp.conf": swanConf,
|
||||
@@ -193,6 +197,56 @@ require-mschap-v2
|
||||
return service.SaveTunnelConfig(t, public, sec)
|
||||
}
|
||||
|
||||
// RefreshAuth rewrites chap-secrets after VPN dial users change.
|
||||
func (s *Service) RefreshAuth(t *model.Tunnel) error {
|
||||
return s.Create(t)
|
||||
}
|
||||
|
||||
type dialUser struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
func loadDialUsers(tunnelID uint) ([]dialUser, error) {
|
||||
if store.DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var users []model.VPNUser
|
||||
if err := store.DB.Where("tunnel_id = ? AND enabled = ? AND source = ?", tunnelID, true, "local").Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dialUser, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.DialSecret == "" {
|
||||
continue
|
||||
}
|
||||
pw, err := store.DecryptSecret(u.DialSecret)
|
||||
if err != nil || pw == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, dialUser{Username: u.Username, Password: pw})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildChapSecrets(defaultUser, defaultPass string, users []dialUser) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# client server secret IP addresses\n")
|
||||
seen := map[string]bool{}
|
||||
if defaultUser != "" && defaultPass != "" {
|
||||
fmt.Fprintf(&b, "%s l2tpd %s *\n", defaultUser, strconv.Quote(defaultPass))
|
||||
seen[defaultUser] = true
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Username == "" || u.Password == "" || seen[u.Username] {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "%s l2tpd %s *\n", u.Username, strconv.Quote(u.Password))
|
||||
seen[u.Username] = true
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// GenerateClientConfig 导出客户端接入说明(手机/Windows 手动配置)
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
@@ -15,18 +17,30 @@ import (
|
||||
"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/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}$`)
|
||||
@@ -34,11 +48,21 @@ 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 := service.LookBin("openvpn"); ok {
|
||||
if p, ok := findOpenVPNBin(); ok {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 openvpn 二进制,可生成配置但无法启动"
|
||||
return false, "未检测到 openvpn 二进制,点击「下载部署」可自动从 GitHub 下载"
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
@@ -55,8 +79,14 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
if service.Str(out, "proto") == "" {
|
||||
out["proto"] = "udp"
|
||||
}
|
||||
if p := service.Str(out, "proto"); p != "udp" && p != "tcp-server" {
|
||||
return nil, fmt.Errorf("proto 必须为 udp 或 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"
|
||||
@@ -84,9 +114,126 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
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
|
||||
}
|
||||
|
||||
@@ -125,7 +272,7 @@ func (s *Service) Create(t *model.Tunnel) error {
|
||||
}
|
||||
// PKI creation is intentionally best-effort: a missing easy-rsa must not be
|
||||
// reported as a running server. Start() verifies every required artifact.
|
||||
_, openvpnInstalled := service.LookBin("openvpn")
|
||||
_, openvpnInstalled := findOpenVPNBin()
|
||||
if service.Bool(cfg, "pki_auto_init", true) && easyRSAPath() != "" && openvpnInstalled && pkiReady(t) != nil {
|
||||
return s.InitializePKI(t)
|
||||
}
|
||||
@@ -136,14 +283,66 @@ func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) stri
|
||||
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
|
||||
fmt.Fprintf(&b, "port %d\nproto %s\ndev tun\nca %q\ncert %q\nkey %q\ndh %q\ntls-crypt %q\n", int(service.Num(cfg, "port", 1194)), service.StrOr(cfg, "proto", "udp"), ca, cert, key, dh, ta)
|
||||
fmt.Fprintf(&b, "topology subnet\nserver %s\nifconfig-pool-persist %q\npush \"dhcp-option DNS %s\"\npush \"redirect-gateway def1 bypass-dhcp\"\n", service.StrOr(cfg, "subnet", "10.10.0.0 255.255.255.0"), filepath.Join(dir, "ipp.txt"), service.StrOr(cfg, "dns", "223.5.5.5"))
|
||||
fmt.Fprintf(&b, "keepalive 10 120\ndata-ciphers %s:AES-128-GCM\npersist-key\npersist-tun\nstatus %q 10\nstatus-version 3\nverb 3\n", service.StrOr(cfg, "cipher", "AES-256-GCM"), filepath.Join(dir, "openvpn-status.log"))
|
||||
|
||||
// 基础监听(对齐生产: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", "223.5.5.5"))
|
||||
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")
|
||||
@@ -151,7 +350,12 @@ func (s *Service) serverConfig(t *model.Tunnel, cfg map[string]interface{}) stri
|
||||
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()
|
||||
}
|
||||
@@ -185,7 +389,7 @@ func (s *Service) InitializePKI(t *model.Tunnel) error {
|
||||
if err := runEasyRSA(dir, easy, "gen-crl"); err != nil {
|
||||
return err
|
||||
}
|
||||
openvpn, ok := service.LookBin("openvpn")
|
||||
openvpn, ok := findOpenVPNBin()
|
||||
if !ok {
|
||||
return fmt.Errorf("PKI 证书已生成,但未检测到 openvpn,无法生成 tls-crypt 密钥")
|
||||
}
|
||||
@@ -245,6 +449,34 @@ func authScript(t *model.Tunnel) string {
|
||||
}
|
||||
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 {
|
||||
@@ -333,6 +565,15 @@ func (s *Service) GenerateUserConfig(t *model.Tunnel, user *model.VPNUser) (stri
|
||||
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 != "" {
|
||||
@@ -342,10 +583,12 @@ func (s *Service) GenerateUserConfig(t *model.Tunnel, user *model.VPNUser) (stri
|
||||
return b.String(), nil
|
||||
}
|
||||
func clientProto(proto string) string {
|
||||
if proto == "tcp-server" {
|
||||
switch strings.ToLower(proto) {
|
||||
case "tcp", "tcp-server", "tcp-client":
|
||||
return "tcp-client"
|
||||
default:
|
||||
return "udp"
|
||||
}
|
||||
return proto
|
||||
}
|
||||
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
@@ -365,16 +608,140 @@ func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
if _, ok := service.LookBin("openvpn"); !ok {
|
||||
if _, ok := findOpenVPNBin(); !ok {
|
||||
return fmt.Errorf("无法启动 OpenVPN:未检测到 openvpn")
|
||||
}
|
||||
if err := pkiReady(t); err != nil {
|
||||
return fmt.Errorf("无法启动 OpenVPN:PKI 尚未就绪:%w", err)
|
||||
}
|
||||
return service.StartDaemon(t, serviceMustBin("openvpn"), "--config", filepath.Join(service.InstanceDir(t), "server.conf"))
|
||||
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 serviceMustBin(name string) string { p, _ := service.LookBin(name); return p }
|
||||
func (s *Service) Stop(t *model.Tunnel) error { return service.KillPid(t) }
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if _, alive := service.ReadPid(t); alive {
|
||||
st := service.NowStatus(true, "running", "OpenVPN 进程运行中")
|
||||
@@ -383,7 +750,7 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
st.TxBytes = t.TxBytes + tx
|
||||
return st, nil
|
||||
}
|
||||
if _, ok := service.LookBin("openvpn"); !ok {
|
||||
if _, ok := findOpenVPNBin(); !ok {
|
||||
return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 openvpn,尚未部署"), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "未启动"), nil
|
||||
@@ -407,6 +774,8 @@ func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) {
|
||||
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" {
|
||||
@@ -422,7 +791,12 @@ func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) {
|
||||
if len(f) > 9 && f[9] != "" && f[9] != "UNDEF" {
|
||||
username = f[9]
|
||||
}
|
||||
key := username + "|" + f[2]
|
||||
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 {
|
||||
@@ -437,9 +811,16 @@ func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) {
|
||||
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) }
|
||||
@@ -447,6 +828,7 @@ 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)
|
||||
@@ -466,16 +848,51 @@ func RunAuthHelper(args []string) error {
|
||||
if len(lines) < 2 || strings.TrimSpace(lines[0]) == "" {
|
||||
return errors.New("认证凭据格式无效")
|
||||
}
|
||||
return Authenticate(uint(*tunnel), strings.TrimSpace(lines[0]), strings.TrimSpace(lines[1]))
|
||||
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)
|
||||
}
|
||||
func Authenticate(tunnelID uint, username, password string) error {
|
||||
if password == "" {
|
||||
|
||||
// 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("密码错误")
|
||||
@@ -512,3 +929,331 @@ func Authenticate(tunnelID uint, username, password string) error {
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestValidateOpenVPNModes(t *testing.T) {
|
||||
@@ -16,6 +22,110 @@ func TestValidateOpenVPNModes(t *testing.T) {
|
||||
if _, err := New().Validate(map[string]interface{}{"auth_mode": "unknown"}); err == nil {
|
||||
t.Fatal("expected invalid auth mode error")
|
||||
}
|
||||
// 前端历史值 tcp 应归一为 tcp-server
|
||||
cfg2, err := New().Validate(map[string]interface{}{"proto": "tcp", "subnet": "10.8.0.0 255.255.255.0"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg2["proto"] != "tcp-server" {
|
||||
t.Fatalf("tcp not normalized: %#v", cfg2)
|
||||
}
|
||||
cfg3, err := New().Validate(map[string]interface{}{
|
||||
"subnet": "10.8.0.0 255.255.255.0", "push_routes": "192.168.1.0/24",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg3["redirect_gateway"] != false {
|
||||
t.Fatalf("push_routes should default redirect_gateway off: %#v", cfg3)
|
||||
}
|
||||
// MFA 仅允许密码相关模式
|
||||
if _, err := New().Validate(map[string]interface{}{
|
||||
"auth_mode": "certificate", "mfa_mode": "required", "subnet": "10.8.0.0 255.255.255.0",
|
||||
}); err == nil {
|
||||
t.Fatal("expected MFA + certificate-only to fail")
|
||||
}
|
||||
cfg4, err := New().Validate(map[string]interface{}{
|
||||
"auth_mode": "password", "mfa_mode": "required", "subnet": "10.8.0.0 255.255.255.0", "proto": "tcp",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg4["proto"] != "tcp-server" || cfg4["mfa_mode"] != "required" {
|
||||
t.Fatalf("mfa/tcp normalize: %#v", cfg4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPasswordAndOTP(t *testing.T) {
|
||||
pw, otp := splitPasswordAndOTP("secretpass123456", "")
|
||||
if pw != "secretpass" || otp != "123456" {
|
||||
t.Fatalf("suffix: %q %q", pw, otp)
|
||||
}
|
||||
scrv := "SCRV1:" + base64.StdEncoding.EncodeToString([]byte("mypass")) + ":" + base64.StdEncoding.EncodeToString([]byte("654321"))
|
||||
pw, otp = splitPasswordAndOTP(scrv, "")
|
||||
if pw != "mypass" || otp != "654321" {
|
||||
t.Fatalf("scrv1: %q %q", pw, otp)
|
||||
}
|
||||
pw, otp = splitPasswordAndOTP("onlypass", "111222")
|
||||
if pw != "onlypass" || otp != "111222" {
|
||||
t.Fatalf("challenge line: %q %q", pw, otp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientProto(t *testing.T) {
|
||||
if clientProto("tcp-server") != "tcp-client" || clientProto("udp") != "udp" || clientProto("tcp") != "tcp-client" {
|
||||
t.Fatal("clientProto mapping failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerConfigIncludesProductionTunings(t *testing.T) {
|
||||
cfg, err := New().Validate(map[string]interface{}{
|
||||
"auth_mode": "password", "proto": "tcp", "subnet": "10.8.0.0 255.255.240.0",
|
||||
"max_clients": 1000, "mfa_mode": "off",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg["proto"] != "tcp-server" {
|
||||
t.Fatalf("proto: %#v", cfg["proto"])
|
||||
}
|
||||
tunn := &model.Tunnel{ID: 1, Protocol: "openvpn"}
|
||||
raw := New().serverConfig(tunn, cfg)
|
||||
for _, want := range []string{
|
||||
"proto tcp-server",
|
||||
"tun-mtu 1500",
|
||||
"mssfix 1450",
|
||||
"keepalive 5 30",
|
||||
"reneg-sec 0",
|
||||
"verify-client-cert none",
|
||||
"username-as-common-name",
|
||||
"max-clients 1000",
|
||||
"duplicate-cn",
|
||||
"via-file",
|
||||
} {
|
||||
if !strings.Contains(raw, want) {
|
||||
t.Fatalf("server.conf missing %q\n%s", want, raw)
|
||||
}
|
||||
}
|
||||
if strings.Contains(raw, "via-env") {
|
||||
t.Fatal("should use via-file not via-env")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTOTPSkew(t *testing.T) {
|
||||
// 使用固定 secret 生成当前码并校验
|
||||
secret := "JBSWY3DPEHPK3PXP" // "Hello!" base32, common test secret
|
||||
code, err := totp.GenerateCode(secret, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ok, counter := validateTOTPSkew(code, secret, 1)
|
||||
if !ok || counter == 0 {
|
||||
t.Fatalf("expected valid totp, ok=%v counter=%d", ok, counter)
|
||||
}
|
||||
if ok2, _ := validateTOTPSkew("000000", secret, 1); ok2 {
|
||||
t.Fatal("expected invalid code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateCNIsSafeAndStable(t *testing.T) {
|
||||
|
||||
@@ -6,8 +6,11 @@ package wireguard
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -140,8 +143,16 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
|
||||
if egress := service.Str(out, "egress_interface"); egress != "" && !ifacePattern.MatchString(egress) {
|
||||
return nil, fmt.Errorf("egress_interface 包含非法字符")
|
||||
}
|
||||
// 启用 NAT 但未指定出口时,自动探测默认路由网卡;探测失败再要求手填。
|
||||
if service.Bool(out, "enable_nat", false) && service.Str(out, "egress_interface") == "" {
|
||||
return nil, fmt.Errorf("启用 NAT 时必须填写 egress_interface")
|
||||
iface, err := service.DefaultRouteInterface()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("启用 NAT 时无法自动探测出口网卡,请填写 egress_interface: %w", err)
|
||||
}
|
||||
out["egress_interface"] = iface
|
||||
}
|
||||
if mtu := int(service.Num(out, "mtu", 0)); mtu != 0 && (mtu < 576 || mtu > 9000) {
|
||||
return nil, fmt.Errorf("mtu 必须在 576-9000 之间")
|
||||
}
|
||||
if _, supplied := out["peers"]; supplied {
|
||||
peers, err := parsePeers(out)
|
||||
@@ -286,18 +297,22 @@ func generateClientConfig(cfg map[string]interface{}, peers []peer, peerSecrets
|
||||
if allowed == "" {
|
||||
allowed = service.StrOr(cfg, "allowed_ips", "0.0.0.0/0, ::/0")
|
||||
}
|
||||
mtuLine := ""
|
||||
if mtu := int(service.Num(cfg, "mtu", 0)); mtu > 0 {
|
||||
mtuLine = fmt.Sprintf("MTU = %d\n", mtu)
|
||||
}
|
||||
return fmt.Sprintf(`[Interface]
|
||||
PrivateKey = %s
|
||||
Address = %s
|
||||
DNS = %s
|
||||
|
||||
%s
|
||||
[Peer]
|
||||
PublicKey = %s
|
||||
PresharedKey = %s
|
||||
Endpoint = %s
|
||||
AllowedIPs = %s
|
||||
PersistentKeepalive = %d
|
||||
`, sec.PrivateKey, p.ClientAddress, service.StrOr(cfg, "dns", "223.5.5.5"), service.Str(cfg, "server_public_key"), sec.PresharedKey, endpoint, allowed, int(service.Num(cfg, "keepalive", 25))), nil
|
||||
`, sec.PrivateKey, p.ClientAddress, service.StrOr(cfg, "dns", "223.5.5.5"), mtuLine, service.Str(cfg, "server_public_key"), sec.PresharedKey, endpoint, allowed, int(service.Num(cfg, "keepalive", 25))), nil
|
||||
}
|
||||
if requestedID != "" {
|
||||
return "", fmt.Errorf("WireGuard Peer 不存在: %s", requestedID)
|
||||
@@ -354,7 +369,11 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
for _, p := range peers {
|
||||
byKey[p.PublicKey] = p
|
||||
}
|
||||
// 内核计数器在接口重启后归零;用 traffic-state 做增量,再叠到库中的累计值。
|
||||
prev := loadTrafficState(t)
|
||||
next := map[string][2]int64{}
|
||||
st := service.NowStatus(true, "running", "WireGuard 接口运行中")
|
||||
var deltaRx, deltaTx int64
|
||||
connected := 0
|
||||
for _, line := range strings.Split(strings.TrimSpace(dump), "\n") {
|
||||
fields := strings.Split(line, "\t")
|
||||
@@ -374,13 +393,25 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if alive {
|
||||
connected++
|
||||
}
|
||||
st.RxBytes += rx
|
||||
st.TxBytes += tx
|
||||
old := prev[fields[0]]
|
||||
dRx, dTx := rx-old[0], tx-old[1]
|
||||
if dRx < 0 {
|
||||
dRx = rx
|
||||
}
|
||||
if dTx < 0 {
|
||||
dTx = tx
|
||||
}
|
||||
deltaRx += dRx
|
||||
deltaTx += dTx
|
||||
next[fields[0]] = [2]int64{rx, tx}
|
||||
st.Peers = append(st.Peers, core.PeerStatus{
|
||||
ID: p.ID, Name: p.Name, PublicKey: fields[0], Endpoint: fields[2], AllowedIPs: fields[3],
|
||||
LatestHandshakeAt: handshake, Connected: alive, RxBytes: rx, TxBytes: tx,
|
||||
})
|
||||
}
|
||||
saveTrafficState(t, next)
|
||||
st.RxBytes = t.RxBytes + deltaRx
|
||||
st.TxBytes = t.TxBytes + deltaTx
|
||||
sort.Slice(st.Peers, func(i, j int) bool { return st.Peers[i].ID < st.Peers[j].ID })
|
||||
st.Detail = fmt.Sprintf("WireGuard 接口 %s 运行中,%d/%d 个 Peer 最近已握手", interfaceName(t), connected, len(st.Peers))
|
||||
return st, nil
|
||||
@@ -388,6 +419,117 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
// AddPeer appends a managed or external peer and rewrites configuration.
|
||||
func (s *Service) AddPeer(t *model.Tunnel, input map[string]interface{}) (peer, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return peer{}, err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return peer{}, err
|
||||
}
|
||||
p := peer{
|
||||
ID: service.Str(input, "id"),
|
||||
Name: service.Str(input, "name"),
|
||||
PublicKey: service.Str(input, "public_key"),
|
||||
ClientAddress: service.Str(input, "client_address"),
|
||||
AllowedIPs: service.Str(input, "allowed_ips"),
|
||||
Enabled: true,
|
||||
Managed: service.Str(input, "public_key") == "",
|
||||
}
|
||||
if p.ID == "" {
|
||||
p.ID = fmt.Sprintf("client-%d", len(peers)+1)
|
||||
}
|
||||
if p.Name == "" {
|
||||
p.Name = p.ID
|
||||
}
|
||||
if p.ClientAddress == "" {
|
||||
return peer{}, fmt.Errorf("client_address 必填")
|
||||
}
|
||||
peers = append(peers, p)
|
||||
cfg["peers"] = peersToConfig(peers)
|
||||
raw, _ := json.Marshal(cfg)
|
||||
t.Config = string(raw)
|
||||
if err := s.Create(t); err != nil {
|
||||
return peer{}, err
|
||||
}
|
||||
// Return the persisted peer (with generated public key when managed).
|
||||
cfg, _ = store.TunnelConfig(t)
|
||||
peers, _ = parsePeers(cfg)
|
||||
for _, item := range peers {
|
||||
if item.ID == p.ID {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// RemovePeer deletes one peer by id and rewrites configuration.
|
||||
func (s *Service) RemovePeer(t *model.Tunnel, peerID string) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next := make([]peer, 0, len(peers))
|
||||
found := false
|
||||
for _, p := range peers {
|
||||
if p.ID == peerID {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
next = append(next, p)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("Peer 不存在: %s", peerID)
|
||||
}
|
||||
if len(next) == 0 {
|
||||
return fmt.Errorf("至少保留一个 Peer")
|
||||
}
|
||||
cfg["peers"] = peersToConfig(next)
|
||||
// Drop removed peer secrets so keys are not retained after revocation.
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
if raw, ok := secrets["peer_secrets"].(map[string]interface{}); ok {
|
||||
delete(raw, peerID)
|
||||
secrets["peer_secrets"] = raw
|
||||
_ = service.SaveTunnelConfig(t, cfg, secrets)
|
||||
}
|
||||
raw, _ := json.Marshal(cfg)
|
||||
t.Config = string(raw)
|
||||
return s.Create(t)
|
||||
}
|
||||
|
||||
func loadTrafficState(t *model.Tunnel) map[string][2]int64 {
|
||||
out := map[string][2]int64{}
|
||||
raw, err := os.ReadFile(filepath.Join(service.InstanceDir(t), "wg-traffic-state.json"))
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
var m map[string][]int64
|
||||
if json.Unmarshal(raw, &m) != nil {
|
||||
return out
|
||||
}
|
||||
for k, v := range m {
|
||||
if len(v) >= 2 {
|
||||
out[k] = [2]int64{v[0], v[1]}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func saveTrafficState(t *model.Tunnel, state map[string][2]int64) {
|
||||
m := make(map[string][]int64, len(state))
|
||||
for k, v := range state {
|
||||
m[k] = []int64{v[0], v[1]}
|
||||
}
|
||||
raw, _ := json.Marshal(m)
|
||||
_ = os.WriteFile(filepath.Join(service.InstanceDir(t), "wg-traffic-state.json"), raw, 0o600)
|
||||
}
|
||||
|
||||
// interfaceName is deterministic, <= IFNAMSIZ-1 (15) and does not depend on
|
||||
// the user supplied tunnel name. This prevents cross-tunnel interface clashes.
|
||||
func interfaceName(t *model.Tunnel) string { return "wg" + strconv.FormatUint(uint64(t.ID), 36) }
|
||||
@@ -640,6 +782,9 @@ func peerSecretsToConfig(secrets map[string]peerSecret) map[string]interface{} {
|
||||
func renderServerConfig(cfg map[string]interface{}, serverPrivate string, peers []peer, secrets map[string]peerSecret) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[Interface]\nPrivateKey = %s\nAddress = %s\nListenPort = %d\n", serverPrivate, service.Str(cfg, "address"), int(service.Num(cfg, "listen_port", defaultPort)))
|
||||
if mtu := int(service.Num(cfg, "mtu", 0)); mtu > 0 {
|
||||
fmt.Fprintf(&b, "MTU = %d\n", mtu)
|
||||
}
|
||||
if service.Bool(cfg, "enable_nat", false) {
|
||||
egress := service.Str(cfg, "egress_interface")
|
||||
fmt.Fprintf(&b, "PostUp = iptables -A FORWARD -i %%i -j ACCEPT; iptables -A FORWARD -o %%i -j ACCEPT; iptables -t nat -A POSTROUTING -o %s -j MASQUERADE\n", egress)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
@@ -67,8 +68,11 @@ func TestBuildPeersKeepsExternalPeerPrivateKeyOutOfPlatform(t *testing.T) {
|
||||
|
||||
func TestValidateRejectsUnsafeNATAndInvalidPeerAddress(t *testing.T) {
|
||||
s := New()
|
||||
if _, err := s.Validate(map[string]interface{}{"address": "10.8.0.1/24", "enable_nat": true}); err == nil {
|
||||
t.Fatal("NAT without explicit egress interface was accepted")
|
||||
// enable_nat without egress:若本机无默认路由则失败;有默认路由则自动填 egress_interface。
|
||||
if cfg, err := s.Validate(map[string]interface{}{"address": "10.8.0.1/24", "enable_nat": true}); err == nil {
|
||||
if service.Str(cfg, "egress_interface") == "" {
|
||||
t.Fatal("NAT auto-detect succeeded but egress_interface is empty")
|
||||
}
|
||||
}
|
||||
_, err := s.Validate(map[string]interface{}{
|
||||
"address": "10.8.0.1/24",
|
||||
|
||||
Reference in new issue
Block a user