feat: implement VPN platform management and real protocol services
This commit is contained in:
commit
58c036c34c
45 files changed
+9244
No files matched your search
@@ -0,0 +1,305 @@
|
||||
// Package service 提供协议实现共用的运行时工具函数
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// BaseDir 实例配置与运行时根目录(可通过 VPN_DATA_DIR 覆盖)
|
||||
func BaseDir() string {
|
||||
if v := os.Getenv("VPN_DATA_DIR"); v != "" {
|
||||
return v
|
||||
}
|
||||
exe, _ := os.Executable()
|
||||
if exe != "" {
|
||||
return filepath.Join(filepath.Dir(exe), "data")
|
||||
}
|
||||
return "data"
|
||||
}
|
||||
|
||||
// InstanceDir 专线实例目录
|
||||
func InstanceDir(t *model.Tunnel) string {
|
||||
return filepath.Join(BaseDir(), "instances", fmt.Sprintf("%d-%s", t.ID, t.Protocol))
|
||||
}
|
||||
|
||||
// LookBin 检测系统依赖二进制
|
||||
func LookBin(names ...string) (string, bool) {
|
||||
for _, n := range names {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Run 执行命令并返回输出
|
||||
func Run(name string, args ...string) (string, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errOut
|
||||
if err := cmd.Run(); err != nil {
|
||||
return out.String(), fmt.Errorf("%s %s 执行失败: %v %s", name, strings.Join(args, " "), err, errOut.String())
|
||||
}
|
||||
return out.String(), nil
|
||||
}
|
||||
|
||||
// RequireRoot rejects service operations that need kernel networking privileges.
|
||||
// Starting the web process as an unprivileged account must never be reported as
|
||||
// a running VPN service merely because configuration generation succeeded.
|
||||
func RequireRoot(component string) error {
|
||||
if os.Geteuid() != 0 {
|
||||
return fmt.Errorf("无法启动 %s:需要 root 权限以管理内核网络、IPSec SA 和防火墙规则", component)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireIPv4Forwarding verifies the kernel prerequisite for forwarding traffic
|
||||
// between a VPN address pool and the LAN/Internet.
|
||||
func RequireIPv4Forwarding(component string) error {
|
||||
sysctl, ok := LookBin("sysctl")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法启动 %s:未检测到 sysctl,无法确认 net.ipv4.ip_forward", component)
|
||||
}
|
||||
out, err := Run(sysctl, "-n", "net.ipv4.ip_forward")
|
||||
if err != nil || strings.TrimSpace(out) != "1" {
|
||||
return fmt.Errorf("无法启动 %s:请先执行 sysctl -w net.ipv4.ip_forward=1", component)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireUDPPortAvailable checks a port before handing it to a daemon. This is
|
||||
// deliberately a real bind check instead of parsing process output, so it also
|
||||
// catches listeners started outside this platform.
|
||||
func RequireUDPPortAvailable(port int, component string) error {
|
||||
conn, err := net.ListenUDP("udp4", &net.UDPAddr{Port: port})
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法启动 %s:UDP %d 已被其他服务占用: %w", component, port, err)
|
||||
}
|
||||
return conn.Close()
|
||||
}
|
||||
|
||||
// DefaultRouteInterface returns the interface used for IPv4 Internet egress.
|
||||
// It is used only when a VPN configuration does not explicitly set one.
|
||||
func DefaultRouteInterface() (string, error) {
|
||||
ip, ok := LookBin("ip")
|
||||
if !ok {
|
||||
return "", fmt.Errorf("未检测到 iproute2 的 ip 命令,无法确定 NAT 出口接口")
|
||||
}
|
||||
out, err := Run(ip, "-4", "route", "show", "default")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fields := strings.Fields(out)
|
||||
for i := 0; i+1 < len(fields); i++ {
|
||||
if fields[i] == "dev" {
|
||||
return fields[i+1], nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("未找到 IPv4 默认路由,无法确定 NAT 出口接口")
|
||||
}
|
||||
|
||||
// StartDaemon 以守护方式启动进程并记录 PID
|
||||
func StartDaemon(t *model.Tunnel, name string, args ...string) error {
|
||||
if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
logFile, err := os.OpenFile(filepath.Join(InstanceDir(t), "instance.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
cmd.Dir = InstanceDir(t)
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return err
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
_ = os.WriteFile(PidFile(t), []byte(strconv.Itoa(pid)), 0o600)
|
||||
go func() { _ = cmd.Wait(); logFile.Close() }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// PidFile 实例 PID 文件
|
||||
func PidFile(t *model.Tunnel) string {
|
||||
return filepath.Join(InstanceDir(t), "instance.pid")
|
||||
}
|
||||
|
||||
// ReadPid 读取实例 PID 并校验存活
|
||||
func ReadPid(t *model.Tunnel) (int, bool) {
|
||||
raw, err := os.ReadFile(PidFile(t))
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
|
||||
if err != nil || pid <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
// signal 0 检测进程存活
|
||||
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
||||
return pid, false
|
||||
}
|
||||
return pid, true
|
||||
}
|
||||
|
||||
// KillPid 停止实例进程
|
||||
func KillPid(t *model.Tunnel) error {
|
||||
pid, alive := ReadPid(t)
|
||||
if !alive {
|
||||
return nil
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
_ = proc.Kill()
|
||||
_, _ = proc.Wait()
|
||||
_ = os.Remove(PidFile(t))
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteConfig 写入实例配置文件(0600 权限)
|
||||
func WriteConfig(t *model.Tunnel, name, content string) (string, error) {
|
||||
if err := os.MkdirAll(InstanceDir(t), 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := filepath.Join(InstanceDir(t), name)
|
||||
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// RandomSecret 生成适合配置文件与 API 凭据使用的不可预测随机值。
|
||||
func RandomSecret(bytesLen int) (string, error) {
|
||||
raw := make([]byte, bytesLen)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
// RemoveInstance 清理隧道的专属配置、日志与 PID 文件。
|
||||
func RemoveInstance(t *model.Tunnel) error {
|
||||
return os.RemoveAll(InstanceDir(t))
|
||||
}
|
||||
|
||||
// Str / StrOr / Num / Bool 从配置 map 中安全取值
|
||||
func Str(cfg map[string]interface{}, key string) string {
|
||||
if v, ok := cfg[key]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func StrOr(cfg map[string]interface{}, key, def string) string {
|
||||
if s := Str(cfg, key); s != "" {
|
||||
return s
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func Num(cfg map[string]interface{}, key string, def float64) float64 {
|
||||
if v, ok := cfg[key]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
case string:
|
||||
if f, err := strconv.ParseFloat(n, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func Bool(cfg map[string]interface{}, key string, def bool) bool {
|
||||
if v, ok := cfg[key]; ok {
|
||||
if b, ok := v.(bool); ok {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// MustValidate 公共校验:配置必须是合法 JSON 对象
|
||||
func MustValidate(cfg map[string]interface{}, required ...string) error {
|
||||
for _, k := range required {
|
||||
if Str(cfg, k) == "" && Num(cfg, k, 0) == 0 {
|
||||
return fmt.Errorf("缺少必填参数: %s", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigJSON 序列化配置
|
||||
func ConfigJSON(cfg map[string]interface{}) string {
|
||||
raw, _ := json.Marshal(cfg)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
// NowStatus 构造状态对象
|
||||
func NowStatus(running bool, state, detail string) core.Status {
|
||||
return core.Status{Running: running, State: state, Detail: detail, UpdatedAt: time.Now().Unix()}
|
||||
}
|
||||
|
||||
// MergeSecrets 从 cfg 中提取敏感字段,返回 (脱敏后的公开配置, 敏感配置)
|
||||
func MergeSecrets(cfg map[string]interface{}, secretKeys ...string) (public map[string]interface{}, secrets map[string]interface{}) {
|
||||
public = map[string]interface{}{}
|
||||
secrets = map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
isSecret := false
|
||||
for _, sk := range secretKeys {
|
||||
if k == sk {
|
||||
isSecret = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if isSecret {
|
||||
secrets[k] = v
|
||||
} else {
|
||||
public[k] = v
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// SaveTunnelConfig 将公开配置与加密密钥写回数据库
|
||||
func SaveTunnelConfig(t *model.Tunnel, public, secrets map[string]interface{}) error {
|
||||
t.Config = ConfigJSON(public)
|
||||
if len(secrets) > 0 {
|
||||
raw, _ := json.Marshal(secrets)
|
||||
enc, err := store.EncryptSecret(string(raw))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Secrets = enc
|
||||
}
|
||||
return store.DB.Save(t).Error
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Package custom 实现自定义协议插件:用户在平台注册命令钩子,
|
||||
// 平台按统一 Protocol 契约调用(Create/Start/Stop/Status/Config)。
|
||||
// 钩子通过 stdin 接收专线 Config JSON,约定 stdout 返回结果。
|
||||
package custom
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
// Service 以数据库中的 Plugin 定义为驱动的协议实现
|
||||
type Service struct {
|
||||
plugin *model.Plugin
|
||||
}
|
||||
|
||||
func New(p *model.Plugin) *Service { return &Service{plugin: p} }
|
||||
|
||||
func (s *Service) Name() string { return "custom:" + s.plugin.Name }
|
||||
func (s *Service) DisplayName() string { return s.plugin.DisplayName }
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if s.plugin.StartHook == "" {
|
||||
return false, "插件未配置 start 钩子"
|
||||
}
|
||||
return true, "插件钩子: " + s.plugin.StartHook
|
||||
}
|
||||
|
||||
// runHook 执行钩子命令,stdin 传入专线上下文 JSON
|
||||
func (s *Service) runHook(hook string, t *model.Tunnel) (string, error) {
|
||||
if hook == "" {
|
||||
return "", fmt.Errorf("插件 [%s] 未配置该动作的钩子命令", s.plugin.Name)
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"id": t.ID,
|
||||
"name": t.Name,
|
||||
"protocol": t.Protocol,
|
||||
"config": json.RawMessage(t.Config),
|
||||
})
|
||||
ctx := exec.Command("sh", "-c", hook)
|
||||
done := make(chan error, 1)
|
||||
ctx.Stdin = bytes.NewReader(payload)
|
||||
var out, errOut bytes.Buffer
|
||||
ctx.Stdout = &out
|
||||
ctx.Stderr = &errOut
|
||||
if err := ctx.Start(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
go func() { done <- ctx.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return out.String(), fmt.Errorf("钩子执行失败: %v %s", err, errOut.String())
|
||||
}
|
||||
return strings.TrimSpace(out.String()), nil
|
||||
case <-time.After(30 * time.Second):
|
||||
_ = ctx.Process.Kill()
|
||||
return "", fmt.Errorf("钩子执行超时(30s)")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(t *model.Tunnel) error {
|
||||
if s.plugin.CreateHook == "" {
|
||||
return nil // 可选
|
||||
}
|
||||
_, err := s.runHook(s.plugin.CreateHook, t)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
_, err := s.runHook(s.plugin.StartHook, t)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Stop(t *model.Tunnel) error {
|
||||
if s.plugin.StopHook == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := s.runHook(s.plugin.StopHook, t)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
if s.plugin.StatusHook == "" {
|
||||
return core.Status{State: "unknown", Detail: "插件未配置 status 钩子", UpdatedAt: timeNow()}, nil
|
||||
}
|
||||
out, err := s.runHook(s.plugin.StatusHook, t)
|
||||
if err != nil {
|
||||
return core.Status{Running: false, State: "error", Detail: err.Error(), UpdatedAt: timeNow()}, nil
|
||||
}
|
||||
// 约定:status 钩子 stdout 输出 running / stopped / 任意描述
|
||||
running := strings.EqualFold(out, "running") || strings.EqualFold(out, "up") || out == "0"
|
||||
state := "stopped"
|
||||
if running {
|
||||
state = "running"
|
||||
}
|
||||
return core.Status{Running: running, State: state, Detail: out, UpdatedAt: timeNow()}, nil
|
||||
}
|
||||
|
||||
func timeNow() int64 { return time.Now().Unix() }
|
||||
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
if s.plugin.ConfigHook == "" {
|
||||
return "", fmt.Errorf("插件未配置 config 钩子,无法生成客户端配置")
|
||||
}
|
||||
return s.runHook(s.plugin.ConfigHook, t)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
// LoadAll 从数据库加载全部启用的插件并注册到管理器
|
||||
func LoadAll(m *core.Manager) {
|
||||
if store.DB == nil {
|
||||
return
|
||||
}
|
||||
var plugins []model.Plugin
|
||||
if err := store.DB.Where("enabled = ?", true).Find(&plugins).Error; err != nil {
|
||||
return
|
||||
}
|
||||
for i := range plugins {
|
||||
m.Register(New(&plugins[i]))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
// Package frp manages real frps/frpc processes and FRP's TOML configuration.
|
||||
package frp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"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{}
|
||||
|
||||
// Proxy is the supported, repeatable FRP proxy rule. It is deliberately kept
|
||||
// as a JSON-friendly struct so callers may put a `proxies` array in Config.
|
||||
// The old single proxy_name fields remain supported for existing installations.
|
||||
type Proxy struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
LocalIP string `json:"local_ip"`
|
||||
LocalPort int `json:"local_port"`
|
||||
RemotePort int `json:"remote_port"`
|
||||
CustomDomains []string `json:"custom_domains"`
|
||||
Subdomain string `json:"subdomain"`
|
||||
Locations []string `json:"locations"`
|
||||
HostHeaderRewrite string `json:"host_header_rewrite"`
|
||||
}
|
||||
|
||||
var safeName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`)
|
||||
|
||||
func New() *Service { return &Service{} }
|
||||
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 {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 frps/frpc 二进制,可生成配置但无法启动"
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
out := clone(cfg)
|
||||
mode := service.StrOr(out, "mode", "server")
|
||||
if mode != "server" && mode != "client" {
|
||||
return nil, fmt.Errorf("mode 必须为 server 或 client")
|
||||
}
|
||||
out["mode"] = mode
|
||||
if mode == "server" {
|
||||
setDefaultPort(out, "bind_port", 7000)
|
||||
setDefaultPort(out, "dashboard_port", 7500)
|
||||
for _, key := range []string{"bind_port", "dashboard_port"} {
|
||||
if err := validPort(key, service.Num(out, key, 0)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"vhost_http_port", "vhost_https_port"} {
|
||||
if service.Num(out, key, 0) != 0 {
|
||||
if err := validPort(key, service.Num(out, key, 0)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := service.MustValidate(out, "server_addr"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setDefaultPort(out, "server_port", 7000)
|
||||
if err := validPort("server_port", service.Num(out, "server_port", 0)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
rules, err := proxyRules(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if err := validateProxy(rule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if len(rules) > 0 {
|
||||
out["proxies"] = proxiesJSON(rules)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func setDefaultPort(cfg map[string]interface{}, key string, value int) {
|
||||
if service.Num(cfg, key, 0) == 0 {
|
||||
cfg[key] = value
|
||||
}
|
||||
}
|
||||
func validPort(name string, port float64) error {
|
||||
if port < 1 || port > 65535 || port != float64(int(port)) {
|
||||
return fmt.Errorf("%s 必须是 1-65535 的整数", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if p.LocalPort < 1 || p.LocalPort > 65535 {
|
||||
return fmt.Errorf("代理 %s 的 local_port 无效", p.Name)
|
||||
}
|
||||
if p.LocalIP == "" {
|
||||
p.LocalIP = "127.0.0.1"
|
||||
}
|
||||
if strings.ContainsAny(p.LocalIP, "\r\n\"'") {
|
||||
return fmt.Errorf("代理 %s 的 local_ip 非法", p.Name)
|
||||
}
|
||||
if p.Type == "tcp" || p.Type == "udp" {
|
||||
if p.RemotePort < 1 || p.RemotePort > 65535 {
|
||||
return fmt.Errorf("代理 %s 的 remote_port 无效", p.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(p.CustomDomains) == 0 && p.Subdomain == "" {
|
||||
return fmt.Errorf("HTTP/HTTPS 代理 %s 必须填写 custom_domains 或 subdomain", p.Name)
|
||||
}
|
||||
domains := append([]string{}, p.CustomDomains...)
|
||||
if p.Subdomain != "" {
|
||||
domains = append(domains, p.Subdomain)
|
||||
}
|
||||
for _, d := range domains {
|
||||
if !validDomain(d) {
|
||||
return fmt.Errorf("代理 %s 的域名 %q 无效", p.Name, d)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validDomain(v string) bool {
|
||||
v = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(v)), ".")
|
||||
if v == "" || len(v) > 253 || strings.ContainsAny(v, "\r\n\"' /\\") {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(v, ".") {
|
||||
if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) Create(t *model.Tunnel) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
token := service.Str(cfg, "token")
|
||||
if token == "" {
|
||||
token = service.Str(secrets, "token")
|
||||
}
|
||||
if token == "" {
|
||||
token, err = service.RandomSecret(32)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
secrets["token"] = token
|
||||
dashboardPassword := service.Str(cfg, "dashboard_password")
|
||||
if dashboardPassword == "" {
|
||||
dashboardPassword = service.Str(secrets, "dashboard_password")
|
||||
}
|
||||
if dashboardPassword == "" {
|
||||
dashboardPassword, err = service.RandomSecret(24)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
secrets["dashboard_password"] = dashboardPassword
|
||||
|
||||
if service.StrOr(cfg, "mode", "server") == "server" {
|
||||
if _, err = service.WriteConfig(t, "frps.toml", frpsConfig(cfg, token, dashboardPassword)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err = service.WriteConfig(t, "frpc.toml", s.frpcConfig(cfg, token)); err != nil {
|
||||
return err
|
||||
}
|
||||
public, sec := service.MergeSecrets(cfg, "token", "dashboard_password")
|
||||
for k, v := range secrets {
|
||||
sec[k] = v
|
||||
}
|
||||
return service.SaveTunnelConfig(t, public, sec)
|
||||
}
|
||||
|
||||
func frpsConfig(cfg map[string]interface{}, token, password string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "bindPort = %d\nauth.method = \"token\"\nauth.token = %q\n\n", int(service.Num(cfg, "bind_port", 7000)), token)
|
||||
fmt.Fprintf(&b, "webServer.addr = %q\nwebServer.port = %d\nwebServer.user = %q\nwebServer.password = %q\n", service.StrOr(cfg, "dashboard_addr", "127.0.0.1"), int(service.Num(cfg, "dashboard_port", 7500)), service.StrOr(cfg, "dashboard_user", "admin"), password)
|
||||
if p := int(service.Num(cfg, "vhost_http_port", 0)); p > 0 {
|
||||
fmt.Fprintf(&b, "vhostHTTPPort = %d\n", p)
|
||||
}
|
||||
if p := int(service.Num(cfg, "vhost_https_port", 0)); p > 0 {
|
||||
fmt.Fprintf(&b, "vhostHTTPSPort = %d\n", p)
|
||||
}
|
||||
if ranges := allowPorts(service.Str(cfg, "allow_ports")); len(ranges) > 0 {
|
||||
fmt.Fprintf(&b, "allowPorts = [%s]\n", strings.Join(ranges, ", "))
|
||||
}
|
||||
b.WriteString("log.to = \"frps.log\"\nlog.level = \"info\"\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *Service) frpcConfig(cfg map[string]interface{}, token string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "serverAddr = %q\nserverPort = %d\nauth.method = \"token\"\nauth.token = %q\nlog.to = \"frpc.log\"\nlog.level = \"info\"\n", service.Str(cfg, "server_addr"), int(service.Num(cfg, "server_port", 7000)), token)
|
||||
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" {
|
||||
fmt.Fprintf(&b, "remotePort = %d\n", p.RemotePort)
|
||||
} else {
|
||||
if len(p.CustomDomains) > 0 {
|
||||
fmt.Fprintf(&b, "customDomains = [%s]\n", strings.Join(quoteStrings(p.CustomDomains), ", "))
|
||||
}
|
||||
if p.Subdomain != "" {
|
||||
fmt.Fprintf(&b, "subdomain = %q\n", p.Subdomain)
|
||||
}
|
||||
if len(p.Locations) > 0 {
|
||||
fmt.Fprintf(&b, "locations = [%s]\n", strings.Join(quoteStrings(p.Locations), ", "))
|
||||
}
|
||||
if p.HostHeaderRewrite != "" {
|
||||
fmt.Fprintf(&b, "hostHeaderRewrite = %q\n", p.HostHeaderRewrite)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
token := service.Str(secrets, "token")
|
||||
if service.StrOr(cfg, "mode", "server") == "server" {
|
||||
cfg = clone(cfg)
|
||||
cfg["server_addr"] = service.StrOr(cfg, "public_endpoint", "your-server-ip")
|
||||
cfg["server_port"] = service.Num(cfg, "bind_port", 7000)
|
||||
}
|
||||
return s.frpcConfig(cfg, token), nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bin, conf := "frpc", "frpc.toml"
|
||||
if service.StrOr(cfg, "mode", "server") == "server" {
|
||||
bin, conf = "frps", "frps.toml"
|
||||
}
|
||||
if p, ok := service.LookBin(bin); ok {
|
||||
return service.StartDaemon(t, p, "-c", fmt.Sprintf("%s/%s", service.InstanceDir(t), conf))
|
||||
}
|
||||
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) {
|
||||
if _, alive := service.ReadPid(t); alive {
|
||||
return service.NowStatus(true, "running", "FRP 进程运行中"), nil
|
||||
}
|
||||
cfg, _ := store.TunnelConfig(t)
|
||||
bin := "frpc"
|
||||
if service.StrOr(cfg, "mode", "server") == "server" {
|
||||
bin = "frps"
|
||||
}
|
||||
if _, ok := service.LookBin(bin); !ok {
|
||||
return service.NowStatus(false, "undeployed", fmt.Sprintf("配置已生成,但未检测到 %s,尚未部署", bin)), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "未启动"), nil
|
||||
}
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
func proxyRules(cfg map[string]interface{}) ([]Proxy, error) {
|
||||
if raw, ok := cfg["proxies"]; ok {
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("proxies 必须是数组")
|
||||
}
|
||||
rules := make([]Proxy, 0, len(items))
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
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.CustomDomains = stringList(m["custom_domains"])
|
||||
p.Locations = stringList(m["locations"])
|
||||
rules = append(rules, p)
|
||||
}
|
||||
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 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})
|
||||
}
|
||||
return out
|
||||
}
|
||||
func stringList(v interface{}) []string {
|
||||
switch x := v.(type) {
|
||||
case []interface{}:
|
||||
out := []string{}
|
||||
for _, item := range x {
|
||||
if s, ok := item.(string); ok && strings.TrimSpace(s) != "" {
|
||||
out = append(out, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return x
|
||||
case string:
|
||||
return splitDomains(x)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func splitDomains(v string) []string {
|
||||
return strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\t' })
|
||||
}
|
||||
func quoteStrings(v []string) []string {
|
||||
out := make([]string, 0, len(v))
|
||||
for _, s := range v {
|
||||
out = append(out, strconv.Quote(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
func defaultString(v, d string) string {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
func clone(in map[string]interface{}) map[string]interface{} {
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
func allowPorts(value string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := []string{}
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
pieces := strings.Split(part, "-")
|
||||
a, err := strconv.Atoi(strings.TrimSpace(pieces[0]))
|
||||
if err != nil || a < 1 || a > 65535 || len(pieces) > 2 {
|
||||
continue
|
||||
}
|
||||
entry := ""
|
||||
if len(pieces) == 1 {
|
||||
entry = fmt.Sprintf("{ single = %d }", a)
|
||||
} else {
|
||||
b, err := strconv.Atoi(strings.TrimSpace(pieces[1]))
|
||||
if err != nil || b < a || b > 65535 {
|
||||
continue
|
||||
}
|
||||
entry = fmt.Sprintf("{ start = %d, end = %d }", a, b)
|
||||
}
|
||||
if !seen[entry] {
|
||||
seen[entry] = true
|
||||
out = append(out, entry)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package frp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateMultipleHTTPAndTCPProxies(t *testing.T) {
|
||||
s := New()
|
||||
cfg, err := s.Validate(map[string]interface{}{
|
||||
"mode": "client",
|
||||
"server_addr": "frps.example.com",
|
||||
"proxies": []interface{}{
|
||||
map[string]interface{}{"name": "ssh", "type": "tcp", "local_port": float64(22), "remote_port": float64(2222)},
|
||||
map[string]interface{}{"name": "web", "type": "https", "local_port": float64(443), "custom_domains": []interface{}{"vpn.example.com"}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := s.frpcConfig(cfg, "test-token")
|
||||
for _, expected := range []string{"name = \"ssh\"", "remotePort = 2222", "name = \"web\"", "customDomains = [\"vpn.example.com\"]"} {
|
||||
if !strings.Contains(text, expected) {
|
||||
t.Errorf("generated config missing %q:\n%s", expected, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPProxyNeedsDomain(t *testing.T) {
|
||||
_, err := New().Validate(map[string]interface{}{
|
||||
"mode": "client", "server_addr": "127.0.0.1",
|
||||
"proxies": []interface{}{map[string]interface{}{"name": "web", "type": "http", "local_port": float64(8080)}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected HTTP domain validation error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
// Package ipsec 实现 IPSec 站点到站点对接管理(StrongSwan swanctl),
|
||||
// 内置 Cisco / FortiGate / 华为 / 华三 常用对接模板,重点服务第三方对接场景。
|
||||
package ipsec
|
||||
|
||||
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 "ipsec" }
|
||||
func (s *Service) DisplayName() string { return "IPSec 站点互联" }
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if p, ok := service.LookBin("swanctl"); ok {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 strongSwan(swanctl),可生成配置但无法启动"
|
||||
}
|
||||
|
||||
// VendorTemplate 第三方设备对接模板
|
||||
type VendorTemplate struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
IKEVersion string `json:"ike_version"`
|
||||
IKEProposal string `json:"ike_proposal"`
|
||||
ESPProposal string `json:"esp_proposal"`
|
||||
DPDAction string `json:"dpd_action"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// Templates 常用第三方设备模板(Phase1 / Phase2 参数预置)
|
||||
func Templates() []VendorTemplate {
|
||||
return []VendorTemplate{
|
||||
{"cisco", "Cisco ISR/ASR", "ikev2", "aes256-sha256-modp2048", "aes256-sha256", "restart", "思科设备建议 IKEv2 + DH group14,注意对端 lifetime 默认 86400s"},
|
||||
{"fortigate", "FortiGate 防火墙", "ikev2", "aes256-sha256-modp1536", "aes256-sha256", "clear", "飞塔默认 DH group5,Phase2 建议开启 PFS group14"},
|
||||
{"huawei", "华为 USG/AR", "ikev2", "aes256-sha1-modp1024", "aes256-sha1", "restart", "华为老设备常用 DH group2,建议升级到 modp2048"},
|
||||
{"h3c", "H3C 防火墙", "ikev1", "aes128-sha1-modp1024", "aes128-sha1", "restart", "华三老型号仅支持 IKEv1 主模式,需关闭 DPD 周期性探测"},
|
||||
{"generic", "通用标准(推荐)", "ikev2", "aes256gcm16-sha256-modp2048", "aes256gcm16", "restart", "现代设备首选:AES-GCM 认证加密,性能与安全兼顾"},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
if err := service.MustValidate(cfg, "remote_gateway", "local_subnets", "remote_subnets"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
if service.Str(out, "ike_version") == "" {
|
||||
out["ike_version"] = "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, "dpd_action") == "" {
|
||||
out["dpd_action"] = "restart"
|
||||
}
|
||||
if service.Str(out, "local_id") == "" {
|
||||
out["local_id"] = "@vpn-local"
|
||||
}
|
||||
if service.Str(out, "remote_id") == "" {
|
||||
out["remote_id"] = "@vpn-remote"
|
||||
}
|
||||
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
|
||||
// generated swanctl stanza.
|
||||
if _, err := netip.ParseAddr(remoteGateway); err != nil &&
|
||||
(strings.ContainsAny(remoteGateway, " \t\r\n{}\"") || !strings.ContainsAny(remoteGateway, ".abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")) {
|
||||
return nil, fmt.Errorf("remote_gateway 必须是 IPv4/IPv6 地址或有效域名")
|
||||
}
|
||||
if err := validatePrefixes(service.Str(out, "local_subnets")); err != nil {
|
||||
return nil, fmt.Errorf("local_subnets 无效: %w", err)
|
||||
}
|
||||
if err := validatePrefixes(service.Str(out, "remote_subnets")); err != nil {
|
||||
return nil, fmt.Errorf("remote_subnets 无效: %w", err)
|
||||
}
|
||||
if intersects(service.Str(out, "local_subnets"), service.Str(out, "remote_subnets")) {
|
||||
return nil, fmt.Errorf("local_subnets 与 remote_subnets 不能重叠")
|
||||
}
|
||||
for _, key := range []string{"ike_proposal", "esp_proposal", "local_id", "remote_id", "dpd_action"} {
|
||||
if strings.ContainsAny(service.Str(out, key), "\r\n{}\"") {
|
||||
return nil, fmt.Errorf("%s 包含配置文件不允许的字符", key)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validatePrefixes(raw string) error {
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
if _, err := netip.ParsePrefix(strings.TrimSpace(item)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func intersects(a, b string) bool {
|
||||
for _, left := range strings.Split(a, ",") {
|
||||
lp, err := netip.ParsePrefix(strings.TrimSpace(left))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, right := range strings.Split(b, ",") {
|
||||
rp, err := netip.ParsePrefix(strings.TrimSpace(right))
|
||||
if err == nil && (lp.Overlaps(rp)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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 // 显式提交新 PSK 时覆盖旧值
|
||||
} else if service.Str(secrets, "psk") == "" {
|
||||
return fmt.Errorf("缺少预共享密钥 psk(第三方对接必填)")
|
||||
}
|
||||
|
||||
conf := s.swanctlConf(t.ID, cfg, service.Str(secrets, "psk"))
|
||||
if _, err := service.WriteConfig(t, "swanctl.conf", conf); err != nil {
|
||||
return err
|
||||
}
|
||||
// 同时导出一份对端参考配置(方便发给第三方)
|
||||
peerRef := s.PeerReference(cfg)
|
||||
if _, err := service.WriteConfig(t, "peer-reference.txt", peerRef); err != nil {
|
||||
return err
|
||||
}
|
||||
public, sec := service.MergeSecrets(cfg, "psk")
|
||||
for k, v := range secrets {
|
||||
sec[k] = v
|
||||
}
|
||||
return service.SaveTunnelConfig(t, public, sec)
|
||||
}
|
||||
|
||||
// swanctlConf 生成 StrongSwan swanctl 配置
|
||||
func (s *Service) swanctlConf(id uint, cfg map[string]interface{}, psk string) string {
|
||||
vip := ""
|
||||
if v := service.Str(cfg, "local_ts_vip"); v != "" {
|
||||
vip = "\n vips = " + v
|
||||
}
|
||||
return fmt.Sprintf(`connections {
|
||||
tunnel-%d {
|
||||
remote_addrs = %s
|
||||
version = %s
|
||||
dpd_delay = 30s
|
||||
dpd_timeout = 120s
|
||||
proposals = %s
|
||||
local {
|
||||
auth = psk
|
||||
id = %s
|
||||
}
|
||||
remote {
|
||||
auth = psk
|
||||
id = %s
|
||||
}
|
||||
children {
|
||||
site-%d {
|
||||
local_ts = %s
|
||||
remote_ts = %s
|
||||
esp_proposals = %s
|
||||
dpd_action = %s
|
||||
start_action = start%s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
secrets {
|
||||
ike-tunnel-%d {
|
||||
id-local = %s
|
||||
id-remote = %s
|
||||
secret = %s
|
||||
}
|
||||
}
|
||||
`,
|
||||
id,
|
||||
service.Str(cfg, "remote_gateway"),
|
||||
ikeVersionNum(service.Str(cfg, "ike_version")),
|
||||
service.Str(cfg, "ike_proposal"),
|
||||
service.Str(cfg, "local_id"),
|
||||
service.Str(cfg, "remote_id"),
|
||||
id,
|
||||
service.Str(cfg, "local_subnets"),
|
||||
service.Str(cfg, "remote_subnets"),
|
||||
service.Str(cfg, "esp_proposal"),
|
||||
service.Str(cfg, "dpd_action"),
|
||||
vip,
|
||||
id,
|
||||
service.Str(cfg, "local_id"),
|
||||
service.Str(cfg, "remote_id"),
|
||||
strconv.Quote(psk),
|
||||
)
|
||||
}
|
||||
|
||||
func ikeVersionNum(v string) string {
|
||||
if strings.EqualFold(v, "ikev1") {
|
||||
return "1"
|
||||
}
|
||||
return "2"
|
||||
}
|
||||
|
||||
// PeerReference 生成对端(第三方)参数参考文档
|
||||
func (s *Service) PeerReference(cfg map[string]interface{}) string {
|
||||
return fmt.Sprintf(`======== IPSec 对接参数(请提供给对端管理员)========
|
||||
|
||||
本端网关公网地址 : %s
|
||||
对端网关地址 : %s
|
||||
IKE 版本 : %s
|
||||
认证方式 : 预共享密钥 (PSK)
|
||||
|
||||
----- Phase 1 (IKE SA) -----
|
||||
加密/认证/DH : %s
|
||||
本端标识 ID : %s
|
||||
对端标识 ID : %s
|
||||
SA 生存期 : %ds
|
||||
DPD 策略 : %s (30s 探测 / 120s 超时)
|
||||
|
||||
----- Phase 2 (IPSec SA) -----
|
||||
封装协议 : ESP (隧道模式)
|
||||
加密/认证 : %s
|
||||
本端网段 : %s
|
||||
对端网段 : %s
|
||||
PFS : %s
|
||||
|
||||
注意:双方 Phase1/Phase2 的加密算法、认证算法、DH 组必须完全一致,
|
||||
两端 ID 需交叉对应(本端 local_id = 对端 remote_id)。
|
||||
`,
|
||||
service.StrOr(cfg, "public_endpoint", "<本端公网 IP>"),
|
||||
service.Str(cfg, "remote_gateway"),
|
||||
service.Str(cfg, "ike_version"),
|
||||
service.Str(cfg, "ike_proposal"),
|
||||
service.Str(cfg, "local_id"),
|
||||
service.Str(cfg, "remote_id"),
|
||||
int(service.Num(cfg, "ike_lifetime", 86400)),
|
||||
service.Str(cfg, "dpd_action"),
|
||||
service.Str(cfg, "esp_proposal"),
|
||||
service.Str(cfg, "local_subnets"),
|
||||
service.Str(cfg, "remote_subnets"),
|
||||
service.StrOr(cfg, "pfs_group", "modp2048"),
|
||||
)
|
||||
}
|
||||
|
||||
// ComparePeerParams 双方参数一致性检查(返回不一致项列表)
|
||||
func ComparePeerParams(local, peer map[string]interface{}) []string {
|
||||
var diffs []string
|
||||
checks := []struct{ key, label string }{
|
||||
{"ike_version", "IKE 版本"},
|
||||
{"ike_proposal", "Phase1 加密/认证/DH 提议"},
|
||||
{"esp_proposal", "Phase2 ESP 提议"},
|
||||
{"pfs_group", "PFS DH 组"},
|
||||
}
|
||||
for _, c := range checks {
|
||||
lv, pv := service.Str(local, c.key), service.Str(peer, c.key)
|
||||
if pv != "" && lv != pv {
|
||||
diffs = append(diffs, fmt.Sprintf("%s 不一致:本端 [%s] vs 对端 [%s]", c.label, lv, pv))
|
||||
}
|
||||
}
|
||||
// 网段交叉检查:本端 local 应等于对端 remote
|
||||
if service.Str(peer, "local_subnets") != "" &&
|
||||
service.Str(local, "remote_subnets") != service.Str(peer, "local_subnets") {
|
||||
diffs = append(diffs, "网段交叉不匹配:本端 remote_subnets 应等于对端 local_subnets")
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
// GenerateClientConfig 导出对端参考配置(第三方对接场景)
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.PeerReference(cfg), nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
confPath := fmt.Sprintf("%s/swanctl.conf", service.InstanceDir(t))
|
||||
bin, ok := service.LookBin("swanctl")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法启动 IPSec:未检测到 swanctl,请先安装 strongSwan")
|
||||
}
|
||||
if err := service.RequireRoot("IPSec"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.RequireIPv4Forwarding("IPSec"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(confPath); err != nil {
|
||||
return fmt.Errorf("无法启动 IPSec:配置文件不存在,请先重新生成配置: %w", err)
|
||||
}
|
||||
if err := s.installFirewall(t); err != nil {
|
||||
return err
|
||||
}
|
||||
// Loading a revised configuration without unloading the previous connection
|
||||
// can leave stale traffic selectors and credentials in charon.
|
||||
_ = s.unload(bin, confPath)
|
||||
if _, err := service.Run(bin, "--load-all", "--file", confPath); err != nil {
|
||||
_ = s.removeFirewall(t)
|
||||
return err
|
||||
}
|
||||
if _, err := service.Run(bin, "--initiate", "--child", fmt.Sprintf("site-%d", t.ID)); err != nil {
|
||||
_ = s.unload(bin, confPath)
|
||||
_ = s.removeFirewall(t)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Stop(t *model.Tunnel) error {
|
||||
var firstErr error
|
||||
if bin, ok := service.LookBin("swanctl"); ok {
|
||||
if _, err := service.Run(bin, "--terminate", "--ike", fmt.Sprintf("tunnel-%d", t.ID)); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
if err := s.unload(bin, fmt.Sprintf("%s/swanctl.conf", service.InstanceDir(t))); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if err := s.removeFirewall(t); 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 {
|
||||
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
|
||||
}
|
||||
if err != nil {
|
||||
return service.NowStatus(false, "error", "无法读取 strongSwan SA 状态: "+err.Error()), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "IPSec Child SA 未建立"), 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 {
|
||||
// Both commands are idempotent from the platform perspective: an already
|
||||
// unloaded connection should not prevent stop/delete from cleaning its files.
|
||||
_, _ = service.Run(bin, "--unload-conns", "--file", confPath)
|
||||
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) installFirewall(t *model.Tunnel) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法启动 IPSec:未检测到 iptables,无法放行 UDP 500/4500 与 ESP")
|
||||
}
|
||||
comment := fmt.Sprintf("opsx-vpn-ipsec-%d", t.ID)
|
||||
rules := [][]string{
|
||||
{"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
{"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
}
|
||||
for _, rule := range rules {
|
||||
check := append([]string{"-C", "INPUT"}, rule...)
|
||||
if _, err := service.Run(iptables, check...); err == nil {
|
||||
continue
|
||||
}
|
||||
add := append([]string{"-A", "INPUT"}, rule...)
|
||||
if _, err := service.Run(iptables, add...); err != nil {
|
||||
_ = s.removeFirewall(t)
|
||||
return fmt.Errorf("无法添加 IPSec 防火墙规则: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) removeFirewall(t *model.Tunnel) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
return nil // Service removal must still work after a package uninstall.
|
||||
}
|
||||
comment := fmt.Sprintf("opsx-vpn-ipsec-%d", t.ID)
|
||||
rules := [][]string{
|
||||
{"-p", "udp", "--dport", "500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
{"-p", "udp", "--dport", "4500", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"},
|
||||
}
|
||||
for _, rule := range rules {
|
||||
for {
|
||||
check := append([]string{"-C", "INPUT"}, rule...)
|
||||
if _, err := service.Run(iptables, check...); err != nil {
|
||||
break
|
||||
}
|
||||
remove := append([]string{"-D", "INPUT"}, rule...)
|
||||
if _, err := service.Run(iptables, remove...); err != nil {
|
||||
return fmt.Errorf("无法移除 IPSec 防火墙规则: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
// Package l2tp 实现 L2TP/IPSec 接入管理(StrongSwan + xl2tpd),兼容手机/老设备。
|
||||
package l2tp
|
||||
|
||||
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 "l2tp" }
|
||||
func (s *Service) DisplayName() string { return "L2TP/IPSec" }
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
_, hasSwan := service.LookBin("swanctl", "ipsec")
|
||||
_, hasL2tp := service.LookBin("xl2tpd")
|
||||
if hasSwan && hasL2tp {
|
||||
return true, "strongSwan + xl2tpd"
|
||||
}
|
||||
return false, "未检测到 strongSwan/xl2tpd,可生成配置但无法启动"
|
||||
}
|
||||
|
||||
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, "ip_range") == "" {
|
||||
out["ip_range"] = "10.9.0.100-10.9.0.200"
|
||||
}
|
||||
if service.Str(out, "local_ip") == "" {
|
||||
out["local_ip"] = "10.9.0.1"
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
}
|
||||
if service.Str(out, "ike_proposal") == "" {
|
||||
out["ike_proposal"] = "aes256-sha256-modp2048,aes128-sha1-modp1024"
|
||||
}
|
||||
if service.Str(out, "esp_proposal") == "" {
|
||||
out["esp_proposal"] = "aes256-sha256,aes128-sha1"
|
||||
}
|
||||
local, err := netip.ParseAddr(service.Str(out, "local_ip"))
|
||||
if err != nil || !local.Is4() {
|
||||
return nil, fmt.Errorf("local_ip 必须是 IPv4 地址")
|
||||
}
|
||||
start, end, err := parseIPRange(service.Str(out, "ip_range"))
|
||||
if err != nil || !start.Is4() || !end.Is4() || start.Compare(end) > 0 {
|
||||
return nil, fmt.Errorf("ip_range 必须是递增的 IPv4 地址范围")
|
||||
}
|
||||
if local.Compare(start) >= 0 && local.Compare(end) <= 0 {
|
||||
return nil, fmt.Errorf("local_ip 不能落在拨号客户端 ip_range 内")
|
||||
}
|
||||
if service.Str(out, "client_subnet") == "" {
|
||||
out["client_subnet"] = netip.PrefixFrom(start, 24).Masked().String()
|
||||
}
|
||||
prefix, err := netip.ParsePrefix(service.Str(out, "client_subnet"))
|
||||
if err != nil || !prefix.Addr().Is4() || !prefix.Contains(start) || !prefix.Contains(end) {
|
||||
return nil, fmt.Errorf("client_subnet 必须覆盖整个 ip_range")
|
||||
}
|
||||
if username := service.Str(out, "username"); username != "" && strings.ContainsAny(username, " \t\r\n\"") {
|
||||
return nil, fmt.Errorf("username 不能包含空白或引号")
|
||||
}
|
||||
for _, key := range []string{"ike_proposal", "esp_proposal"} {
|
||||
if strings.ContainsAny(service.Str(out, key), "\r\n{}\"") {
|
||||
return nil, fmt.Errorf("%s 包含配置文件不允许的字符", key)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseIPRange(raw string) (netip.Addr, netip.Addr, error) {
|
||||
parts := strings.Split(raw, "-")
|
||||
if len(parts) != 2 {
|
||||
return netip.Addr{}, netip.Addr{}, fmt.Errorf("格式应为 起始IP-结束IP")
|
||||
}
|
||||
start, err := netip.ParseAddr(strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
return netip.Addr{}, netip.Addr{}, err
|
||||
}
|
||||
end, err := netip.ParseAddr(strings.TrimSpace(parts[1]))
|
||||
return start, end, err
|
||||
}
|
||||
|
||||
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") == "" {
|
||||
return fmt.Errorf("缺少 IPSec 预共享密钥 psk")
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
// 1) IPSec 层(transport 模式,L2TP over IPSec)
|
||||
swanConf := fmt.Sprintf(`connections {
|
||||
l2tp-%d {
|
||||
type = transport
|
||||
proposals = %s
|
||||
local { auth = psk }
|
||||
remote { auth = psk }
|
||||
children {
|
||||
l2tp-child-%d {
|
||||
local_ts = dynamic[udp/l2tp]
|
||||
remote_ts = dynamic[udp/l2tp]
|
||||
esp_proposals = %s
|
||||
start_action = trap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
secrets {
|
||||
ike-l2tp-%d {
|
||||
secret = %s
|
||||
}
|
||||
}
|
||||
`, t.ID, service.Str(cfg, "ike_proposal"), t.ID, service.Str(cfg, "esp_proposal"), t.ID, strconv.Quote(service.Str(secrets, "psk")))
|
||||
|
||||
// 2) L2TP 层
|
||||
xl2tpdConf := fmt.Sprintf(`[global]
|
||||
port = 1701
|
||||
access control = yes
|
||||
auth file = %s/chap-secrets
|
||||
|
||||
[lns default]
|
||||
ip range = %s
|
||||
local ip = %s
|
||||
require chap = yes
|
||||
refuse pap = yes
|
||||
require authentication = yes
|
||||
name = opsx-l2tp-%d
|
||||
ppp debug = no
|
||||
pppoptfile = %s/ppp-options
|
||||
length bit = yes
|
||||
`, service.InstanceDir(t), service.Str(cfg, "ip_range"), service.Str(cfg, "local_ip"), t.ID, service.InstanceDir(t))
|
||||
|
||||
pppOpts := fmt.Sprintf(`ms-dns %s
|
||||
asyncmap 0
|
||||
auth
|
||||
ipcp-accept-local
|
||||
ipcp-accept-remote
|
||||
nodefaultroute
|
||||
crtscts
|
||||
lock
|
||||
hide-password
|
||||
modem
|
||||
debug
|
||||
name l2tpd
|
||||
proxyarp
|
||||
lcp-echo-interval 30
|
||||
lcp-echo-failure 4
|
||||
mtu 1400
|
||||
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")))
|
||||
|
||||
for name, content := range map[string]string{
|
||||
"swanctl-l2tp.conf": swanConf,
|
||||
"xl2tpd.conf": xl2tpdConf,
|
||||
"ppp-options": pppOpts,
|
||||
"chap-secrets": chapSecrets,
|
||||
} {
|
||||
if _, err := service.WriteConfig(t, name, content); 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)
|
||||
}
|
||||
|
||||
// GenerateClientConfig 导出客户端接入说明(手机/Windows 手动配置)
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secrets, _ := store.TunnelSecrets(t)
|
||||
return fmt.Sprintf(`======== L2TP/IPSec 客户端接入信息 ========
|
||||
|
||||
服务器地址 : %s
|
||||
VPN 类型 : L2TP/IPSec PSK(预共享密钥)
|
||||
IPSec PSK : %s
|
||||
用户名 : %s
|
||||
密码 : %s
|
||||
|
||||
客户端配置指引:
|
||||
- iOS/macOS : 设置 → VPN → 添加 L2TP,填入服务器/账号/密码/密钥
|
||||
- Windows : 设置 → VPN → L2TP/IPsec 预共享密钥,需注册表开启 NAT-T
|
||||
- Android : 部分新机型已移除 L2TP,建议改用 WireGuard
|
||||
- 路由器 : 选择 L2TP 拨号,填入上述参数
|
||||
`,
|
||||
service.StrOr(cfg, "public_endpoint", "<服务器公网 IP>"),
|
||||
secrets["psk"],
|
||||
service.StrOr(cfg, "username", "vpnuser"),
|
||||
secrets["password"],
|
||||
), nil
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
dir := service.InstanceDir(t)
|
||||
swanctl, hasSwan := service.LookBin("swanctl")
|
||||
xl2tpd, hasL2tp := service.LookBin("xl2tpd")
|
||||
if !hasSwan || !hasL2tp {
|
||||
return fmt.Errorf("无法启动 L2TP/IPSec:需要 strongSwan(swanctl)和 xl2tpd")
|
||||
}
|
||||
if err := service.RequireRoot("L2TP/IPSec"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.RequireIPv4Forwarding("L2TP/IPSec"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(dir + "/swanctl-l2tp.conf"); err != nil {
|
||||
return fmt.Errorf("无法启动 L2TP/IPSec:配置文件不存在,请先重新生成配置: %w", err)
|
||||
}
|
||||
if err := s.ensureExclusiveListener(t); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.RequireUDPPortAvailable(1701, "L2TP/IPSec"); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.installFirewall(t, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.unload(swanctl, dir+"/swanctl-l2tp.conf")
|
||||
if _, err := service.Run(swanctl, "--load-all", "--file", dir+"/swanctl-l2tp.conf"); err != nil {
|
||||
_ = s.removeFirewall(t, cfg)
|
||||
return err
|
||||
}
|
||||
if err := service.StartDaemon(t, xl2tpd, "-c", dir+"/xl2tpd.conf", "-p", dir+"/xl2tpd.pid", "-D"); err != nil {
|
||||
_ = s.unload(swanctl, dir+"/swanctl-l2tp.conf")
|
||||
_ = 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("l2tp-%d", t.ID))
|
||||
if err := s.unload(bin, service.InstanceDir(t)+"/swanctl-l2tp.conf"); err != nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if err := service.KillPid(t); err != nil && firstErr == 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 _, alive := service.ReadPid(t); alive {
|
||||
detail := "L2TP 服务正在 UDP 1701 监听,等待客户端连接"
|
||||
if _, ok := service.LookBin("xl2tpd"); !ok {
|
||||
detail = "托管进程仍在运行,但 xl2tpd 当前不可用"
|
||||
}
|
||||
if swanctl, ok := service.LookBin("swanctl"); ok {
|
||||
if out, err := service.Run(swanctl, "--list-sas"); err == nil && strings.Contains(out, fmt.Sprintf("l2tp-child-%d", t.ID)) {
|
||||
detail = "L2TP/IPSec 服务运行中,已有已建立的 IPSec SA"
|
||||
}
|
||||
}
|
||||
return service.NowStatus(true, "running", detail), nil
|
||||
}
|
||||
if ok, _ := s.Available(); !ok {
|
||||
return service.NowStatus(false, "undeployed", "配置已生成,但缺少 strongSwan 或 xl2tpd,尚未部署"), nil
|
||||
}
|
||||
return service.NowStatus(false, "stopped", "未启动"), nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
// xl2tpd owns a host-wide UDP socket. Independent configs cannot safely share
|
||||
// it; without this guard a later instance would look started but accept no VPN
|
||||
// sessions. Network namespaces can deliberately lift this restriction later.
|
||||
func (s *Service) ensureExclusiveListener(t *model.Tunnel) error {
|
||||
var count int64
|
||||
if err := store.DB.Model(&model.Tunnel{}).Where("protocol = ? AND state = ? AND id <> ?", "l2tp", "running", t.ID).Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("检查 L2TP 监听冲突失败: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("无法启动 L2TP/IPSec:本机已有 L2TP 实例运行;xl2tpd 共享 UDP 1701,每台主机仅支持一个实例")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) unload(bin, confPath string) error {
|
||||
// swanctl reports an error when an object was never loaded. Treat that as
|
||||
// success so Stop/Delete remain idempotent after a partial failed startup.
|
||||
_, _ = service.Run(bin, "--unload-conns", "--file", confPath)
|
||||
_, _ = service.Run(bin, "--unload-creds", "--file", confPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
type firewallRule struct {
|
||||
table, chain string
|
||||
args []string
|
||||
}
|
||||
|
||||
func (s *Service) firewallRules(t *model.Tunnel, cfg map[string]interface{}) ([]firewallRule, error) {
|
||||
outbound := service.Str(cfg, "outbound_interface")
|
||||
if outbound == "" {
|
||||
var err error
|
||||
outbound, err = service.DefaultRouteInterface()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
comment := fmt.Sprintf("opsx-vpn-l2tp-%d", t.ID)
|
||||
return []firewallRule{
|
||||
{"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", "udp", "--dport", "1701", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "INPUT", []string{"-p", "esp", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "FORWARD", []string{"-i", "ppp+", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"filter", "FORWARD", []string{"-o", "ppp+", "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-m", "comment", "--comment", comment, "-j", "ACCEPT"}},
|
||||
{"nat", "POSTROUTING", []string{"-s", service.Str(cfg, "client_subnet"), "-o", outbound, "-m", "comment", "--comment", comment, "-j", "MASQUERADE"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) installFirewall(t *model.Tunnel, cfg map[string]interface{}) error {
|
||||
iptables, ok := service.LookBin("iptables")
|
||||
if !ok {
|
||||
return fmt.Errorf("无法启动 L2TP/IPSec:未检测到 iptables,无法配置防火墙和 NAT")
|
||||
}
|
||||
rules, err := s.firewallRules(t, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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("无法添加 L2TP/IPSec 防火墙规则: %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
|
||||
}
|
||||
rules, err := s.firewallRules(t, cfg)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
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 fmt.Errorf("无法移除 L2TP/IPSec 防火墙规则: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
// 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 (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"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/store"
|
||||
)
|
||||
|
||||
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" }
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if p, ok := service.LookBin("openvpn"); ok {
|
||||
return true, p
|
||||
}
|
||||
return false, "未检测到 openvpn 二进制,可生成配置但无法启动"
|
||||
}
|
||||
|
||||
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.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"
|
||||
}
|
||||
if p := service.Str(out, "proto"); p != "udp" && p != "tcp-server" {
|
||||
return nil, fmt.Errorf("proto 必须为 udp 或 tcp-server")
|
||||
}
|
||||
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"] = "223.5.5.5"
|
||||
}
|
||||
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")
|
||||
}
|
||||
if _, ok := out["pki_auto_init"]; !ok {
|
||||
out["pki_auto_init"] = true
|
||||
}
|
||||
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 := service.LookBin("openvpn")
|
||||
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
|
||||
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"))
|
||||
if service.StrOr(cfg, "proto", "udp") == "udp" {
|
||||
b.WriteString("explicit-exit-notify 1\n")
|
||||
}
|
||||
switch service.StrOr(cfg, "auth_mode", "certificate") {
|
||||
case "password":
|
||||
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" {
|
||||
fmt.Fprintf(&b, "script-security 2\nauth-user-pass-verify %q via-file\n", filepath.Join(dir, "auth-user-pass.sh"))
|
||||
}
|
||||
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 := service.LookBin("openvpn")
|
||||
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, "'", "'\\''") + "'" }
|
||||
|
||||
// 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("<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 {
|
||||
if proto == "tcp-server" {
|
||||
return "tcp-client"
|
||||
}
|
||||
return proto
|
||||
}
|
||||
|
||||
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 := service.LookBin("openvpn"); !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"))
|
||||
}
|
||||
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 进程运行中")
|
||||
rx, tx := s.collectTraffic(t)
|
||||
st.RxBytes = t.RxBytes + rx
|
||||
st.TxBytes = t.TxBytes + tx
|
||||
return st, nil
|
||||
}
|
||||
if _, ok := service.LookBin("openvpn"); !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()
|
||||
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]
|
||||
}
|
||||
key := username + "|" + f[2]
|
||||
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})
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(next)
|
||||
_ = os.WriteFile(filepath.Join(service.InstanceDir(t), "traffic-state.json"), b, 0o600)
|
||||
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.
|
||||
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("认证凭据格式无效")
|
||||
}
|
||||
return Authenticate(uint(*tunnel), strings.TrimSpace(lines[0]), strings.TrimSpace(lines[1]))
|
||||
}
|
||||
func Authenticate(tunnelID uint, username, password string) error {
|
||||
if password == "" {
|
||||
return errors.New("密码为空")
|
||||
}
|
||||
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 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 未启用")
|
||||
}
|
||||
url := cfg.URL
|
||||
if !strings.HasPrefix(url, "ldap://") && !strings.HasPrefix(url, "ldaps://") {
|
||||
url = "ldap://" + url
|
||||
}
|
||||
conn, err := ldap.DialURL(url)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package openvpn
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateOpenVPNModes(t *testing.T) {
|
||||
cfg, err := New().Validate(map[string]interface{}{"auth_mode": "password", "subnet": "10.8.0.0 255.255.255.0"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg["proto"] != "udp" || cfg["port"] != 1194 {
|
||||
t.Fatalf("defaults not applied: %#v", cfg)
|
||||
}
|
||||
if _, err := New().Validate(map[string]interface{}{"auth_mode": "unknown"}); err == nil {
|
||||
t.Fatal("expected invalid auth mode error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateCNIsSafeAndStable(t *testing.T) {
|
||||
cn := certificateCN("张三 / alice@example.com")
|
||||
if !certificateName.MatchString(cn) || !strings.HasPrefix(cn, "u-") {
|
||||
t.Fatalf("unsafe CN: %q", cn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
// Package wireguard manages real WireGuard interfaces through wg-quick and wg.
|
||||
// Every tunnel owns one interface and any number of peers. Private material is
|
||||
// retained in Tunnel.Secrets; the JSON configuration only contains public keys.
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/curve25519"
|
||||
|
||||
"vpn-platform/backend/internal/core"
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/service"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPort = 51820
|
||||
maxListenPort = 60999
|
||||
handshakeAlive = 3 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
peerIDPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$`)
|
||||
ifacePattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]{1,15}$`)
|
||||
portMu sync.Mutex
|
||||
)
|
||||
|
||||
type Service struct{}
|
||||
|
||||
// peer is the public, persisted description of a WireGuard peer. private_key
|
||||
// and preshared_key deliberately never appear here.
|
||||
type peer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
PublicKey string `json:"public_key"`
|
||||
ClientAddress string `json:"client_address"`
|
||||
AllowedIPs string `json:"allowed_ips,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Managed bool `json:"managed"`
|
||||
}
|
||||
|
||||
type peerSecret struct {
|
||||
PrivateKey string `json:"private_key,omitempty"`
|
||||
PresharedKey string `json:"preshared_key,omitempty"`
|
||||
}
|
||||
|
||||
func New() *Service { return &Service{} }
|
||||
|
||||
func (s *Service) Name() string { return "wireguard" }
|
||||
func (s *Service) DisplayName() string { return "WireGuard" }
|
||||
|
||||
func (s *Service) Available() (bool, string) {
|
||||
if p, ok := service.LookBin("wg-quick"); ok {
|
||||
if _, wgOK := service.LookBin("wg"); wgOK {
|
||||
return true, p
|
||||
}
|
||||
}
|
||||
return false, "未检测到 wireguard-tools(需要 wg 与 wg-quick)"
|
||||
}
|
||||
|
||||
// genKey generates a WireGuard-compatible Curve25519 private/public key pair.
|
||||
func genKey() (priv, pub string, err error) {
|
||||
var sk [32]byte
|
||||
if _, err = rand.Read(sk[:]); err != nil {
|
||||
return
|
||||
}
|
||||
sk[0] &= 248
|
||||
sk[31] = (sk[31] & 127) | 64
|
||||
pk, err := curve25519.X25519(sk[:], curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
priv = base64.StdEncoding.EncodeToString(sk[:])
|
||||
pub = base64.StdEncoding.EncodeToString(pk)
|
||||
return
|
||||
}
|
||||
|
||||
func publicKey(privateKey string) (string, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(privateKey)
|
||||
if err != nil || len(raw) != curve25519.ScalarSize {
|
||||
return "", fmt.Errorf("WireGuard 私钥格式非法")
|
||||
}
|
||||
pub, err := curve25519.X25519(raw, curve25519.Basepoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(pub), nil
|
||||
}
|
||||
|
||||
func isWireGuardKey(key string) bool {
|
||||
raw, err := base64.StdEncoding.DecodeString(key)
|
||||
return err == nil && len(raw) == curve25519.PointSize
|
||||
}
|
||||
|
||||
func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{}, error) {
|
||||
if err := service.MustValidate(cfg, "address"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
if !validCIDR(service.Str(out, "address")) {
|
||||
return nil, fmt.Errorf("address 必须是合法 CIDR")
|
||||
}
|
||||
port := int(service.Num(out, "listen_port", 0))
|
||||
if port < 0 || port > 65535 || (port > 0 && port < 1024) {
|
||||
return nil, fmt.Errorf("listen_port 必须是 1024-65535 之间的 UDP 端口")
|
||||
}
|
||||
// Port zero means "allocate a free WireGuard port when this tunnel is created".
|
||||
if port > 0 {
|
||||
out["listen_port"] = port
|
||||
}
|
||||
if service.Str(out, "allowed_ips") == "" {
|
||||
out["allowed_ips"] = "0.0.0.0/0, ::/0"
|
||||
}
|
||||
if err := validateCIDRList(service.Str(out, "allowed_ips")); err != nil {
|
||||
return nil, fmt.Errorf("allowed_ips: %w", err)
|
||||
}
|
||||
if service.Str(out, "dns") == "" {
|
||||
out["dns"] = "223.5.5.5"
|
||||
}
|
||||
if service.Num(out, "keepalive", 0) == 0 {
|
||||
out["keepalive"] = 25
|
||||
}
|
||||
keepalive := int(service.Num(out, "keepalive", 25))
|
||||
if keepalive < 0 || keepalive > 65535 {
|
||||
return nil, fmt.Errorf("keepalive 必须是 0-65535")
|
||||
}
|
||||
if egress := service.Str(out, "egress_interface"); egress != "" && !ifacePattern.MatchString(egress) {
|
||||
return nil, fmt.Errorf("egress_interface 包含非法字符")
|
||||
}
|
||||
if service.Bool(out, "enable_nat", false) && service.Str(out, "egress_interface") == "" {
|
||||
return nil, fmt.Errorf("启用 NAT 时必须填写 egress_interface")
|
||||
}
|
||||
if _, supplied := out["peers"]; supplied {
|
||||
peers, err := parsePeers(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePeerInput(peers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if addr := service.Str(out, "client_address"); addr != "" && !validCIDR(addr) {
|
||||
return nil, fmt.Errorf("client_address 必须是合法 CIDR")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(t *model.Tunnel) error {
|
||||
// Config allocation and persistence need to be serialised: otherwise two
|
||||
// concurrent create requests can both observe the same currently-free port.
|
||||
portMu.Lock()
|
||||
defer portMu.Unlock()
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets, err := store.TunnelSecrets(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if int(service.Num(cfg, "listen_port", 0)) == 0 {
|
||||
port, err := allocatePort(t.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg["listen_port"] = port
|
||||
} else if err := ensurePortAvailable(t.ID, int(service.Num(cfg, "listen_port", 0))); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg["interface_name"] = interfaceName(t)
|
||||
|
||||
serverPrivate, _ := secrets["server_private_key"].(string)
|
||||
if serverPrivate == "" {
|
||||
var serverPublic string
|
||||
serverPrivate, serverPublic, err = genKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secrets["server_private_key"] = serverPrivate
|
||||
cfg["server_public_key"] = serverPublic
|
||||
} else {
|
||||
serverPublic, err := publicKey(serverPrivate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg["server_public_key"] = serverPublic
|
||||
}
|
||||
|
||||
peers, peerSecrets, err := buildPeers(cfg, secrets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(peers) == 0 {
|
||||
return fmt.Errorf("至少需要一个 WireGuard Peer")
|
||||
}
|
||||
cfg["peers"] = peersToConfig(peers)
|
||||
// Legacy single-client fields must not leak into Config or conflict with peers.
|
||||
delete(cfg, "client_address")
|
||||
delete(cfg, "client_public_key")
|
||||
delete(secrets, "client_private_key")
|
||||
delete(secrets, "preshared_key")
|
||||
secrets["peer_secrets"] = peerSecretsToConfig(peerSecrets)
|
||||
|
||||
serverConf := renderServerConfig(cfg, serverPrivate, peers, peerSecrets)
|
||||
if _, err := service.WriteConfig(t, interfaceName(t)+".conf", serverConf); err != nil {
|
||||
return err
|
||||
}
|
||||
// client.conf remains a convenience export for the first platform-managed peer.
|
||||
if clientConf, err := generateClientConfig(cfg, peers, peerSecrets, ""); err == nil {
|
||||
if _, err := service.WriteConfig(t, "client.conf", clientConf); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
public, sec := service.MergeSecrets(cfg, "server_private_key")
|
||||
for k, v := range secrets {
|
||||
sec[k] = v
|
||||
}
|
||||
return service.SaveTunnelConfig(t, public, sec)
|
||||
}
|
||||
|
||||
// GenerateClientConfig exports the first managed peer for backward compatible
|
||||
// downloads. Use GeneratePeerClientConfig when the caller has selected a peer.
|
||||
func (s *Service) GenerateClientConfig(t *model.Tunnel) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secrets, err := store.TunnelSecrets(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return generateClientConfig(cfg, peers, parsePeerSecrets(secrets), "")
|
||||
}
|
||||
|
||||
// GeneratePeerClientConfig generates one device's configuration without exposing
|
||||
// any other peer's key material.
|
||||
func (s *Service) GeneratePeerClientConfig(t *model.Tunnel, peerID string) (string, error) {
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secrets, err := store.TunnelSecrets(t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
peerSecrets := parsePeerSecrets(secrets)
|
||||
return generateClientConfig(cfg, peers, peerSecrets, peerID)
|
||||
}
|
||||
|
||||
func generateClientConfig(cfg map[string]interface{}, peers []peer, peerSecrets map[string]peerSecret, requestedID string) (string, error) {
|
||||
for _, p := range peers {
|
||||
if requestedID != "" && p.ID != requestedID {
|
||||
continue
|
||||
}
|
||||
if requestedID == "" && (!p.Managed || !p.Enabled) {
|
||||
continue
|
||||
}
|
||||
sec := peerSecrets[p.ID]
|
||||
if !p.Managed || sec.PrivateKey == "" {
|
||||
return "", fmt.Errorf("Peer %s 为外部设备,平台未保存其私钥", p.ID)
|
||||
}
|
||||
endpoint := service.StrOr(cfg, "public_endpoint", fmt.Sprintf("your-server-ip:%d", int(service.Num(cfg, "listen_port", defaultPort))))
|
||||
allowed := p.AllowedIPs
|
||||
if allowed == "" {
|
||||
allowed = service.StrOr(cfg, "allowed_ips", "0.0.0.0/0, ::/0")
|
||||
}
|
||||
return fmt.Sprintf(`[Interface]
|
||||
PrivateKey = %s
|
||||
Address = %s
|
||||
DNS = %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
|
||||
}
|
||||
if requestedID != "" {
|
||||
return "", fmt.Errorf("WireGuard Peer 不存在: %s", requestedID)
|
||||
}
|
||||
return "", fmt.Errorf("没有可下载配置的托管 Peer;外部 Peer 请在其设备上配置")
|
||||
}
|
||||
|
||||
func (s *Service) Start(t *model.Tunnel) error {
|
||||
if available, _ := s.Available(); !available {
|
||||
return fmt.Errorf("无法启动 WireGuard:未检测到 wg 和 wg-quick,请先安装 wireguard-tools")
|
||||
}
|
||||
if _, err := service.Run("wg", "show", interfaceName(t)); err == nil {
|
||||
return nil
|
||||
}
|
||||
conf := fmt.Sprintf("%s/%s.conf", service.InstanceDir(t), interfaceName(t))
|
||||
if _, err := service.Run("wg-quick", "up", conf); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Stop(t *model.Tunnel) error {
|
||||
if _, ok := service.LookBin("wg-quick"); !ok {
|
||||
return fmt.Errorf("无法停止 WireGuard:未检测到 wg-quick")
|
||||
}
|
||||
if _, ok := service.LookBin("wg"); ok {
|
||||
if _, err := service.Run("wg", "show", interfaceName(t)); err != nil {
|
||||
return nil // already stopped; do not claim a fake process was stopped
|
||||
}
|
||||
}
|
||||
conf := fmt.Sprintf("%s/%s.conf", service.InstanceDir(t), interfaceName(t))
|
||||
_, err := service.Run("wg-quick", "down", conf)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
|
||||
bin, ok := service.LookBin("wg")
|
||||
if !ok {
|
||||
return service.NowStatus(false, "undeployed", "配置已生成,但未检测到 wireguard-tools(wg),尚未部署"), nil
|
||||
}
|
||||
dump, err := service.Run(bin, "show", interfaceName(t), "dump")
|
||||
if err != nil {
|
||||
return service.NowStatus(false, "stopped", "WireGuard 接口未启动"), nil
|
||||
}
|
||||
cfg, err := store.TunnelConfig(t)
|
||||
if err != nil {
|
||||
return core.Status{}, err
|
||||
}
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return core.Status{}, err
|
||||
}
|
||||
byKey := make(map[string]peer, len(peers))
|
||||
for _, p := range peers {
|
||||
byKey[p.PublicKey] = p
|
||||
}
|
||||
st := service.NowStatus(true, "running", "WireGuard 接口运行中")
|
||||
connected := 0
|
||||
for _, line := range strings.Split(strings.TrimSpace(dump), "\n") {
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) < 8 { // the first interface row has five columns
|
||||
continue
|
||||
}
|
||||
p, known := byKey[fields[0]]
|
||||
if !known {
|
||||
// Preserve visibility for a manually added peer instead of silently
|
||||
// attributing it to a configured client.
|
||||
p = peer{ID: "unknown-" + fields[0][:min(8, len(fields[0]))], PublicKey: fields[0], Enabled: true}
|
||||
}
|
||||
handshake, _ := strconv.ParseInt(fields[4], 10, 64)
|
||||
rx, _ := strconv.ParseInt(fields[5], 10, 64)
|
||||
tx, _ := strconv.ParseInt(fields[6], 10, 64)
|
||||
alive := handshake > 0 && time.Since(time.Unix(handshake, 0)) <= handshakeAlive
|
||||
if alive {
|
||||
connected++
|
||||
}
|
||||
st.RxBytes += rx
|
||||
st.TxBytes += 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,
|
||||
})
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
|
||||
|
||||
// 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) }
|
||||
|
||||
func allocatePort(tunnelID uint) (int, error) {
|
||||
for port := defaultPort; port <= maxListenPort; port++ {
|
||||
if ensurePortAvailable(tunnelID, port) == nil {
|
||||
return port, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("没有可用的 WireGuard UDP 端口(%d-%d)", defaultPort, maxListenPort)
|
||||
}
|
||||
|
||||
func ensurePortAvailable(tunnelID uint, port int) error {
|
||||
var tunnels []model.Tunnel
|
||||
if store.DB == nil {
|
||||
return nil
|
||||
}
|
||||
if err := store.DB.Where("protocol = ? AND id <> ?", "wireguard", tunnelID).Find(&tunnels).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, other := range tunnels {
|
||||
cfg, err := store.TunnelConfig(&other)
|
||||
if err == nil && int(service.Num(cfg, "listen_port", defaultPort)) == port {
|
||||
return fmt.Errorf("WireGuard UDP 端口 %d 已被专线 %q 占用", port, other.Name)
|
||||
}
|
||||
}
|
||||
if !hostUDPPortAvailable(port) {
|
||||
return fmt.Errorf("WireGuard UDP 端口 %d 已被本机其他服务占用", port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hostUDPPortAvailable(port int) bool {
|
||||
listener, err := net.ListenUDP("udp4", &net.UDPAddr{Port: port})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return listener.Close() == nil
|
||||
}
|
||||
|
||||
func buildPeers(cfg, secrets map[string]interface{}) ([]peer, map[string]peerSecret, error) {
|
||||
peers, err := parsePeers(cfg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Compatibility migration: old one-client configs become client-1.
|
||||
if len(peers) == 0 {
|
||||
address := service.StrOr(cfg, "client_address", "10.8.0.2/32")
|
||||
if !validCIDR(address) {
|
||||
return nil, nil, fmt.Errorf("client_address 必须是合法 CIDR")
|
||||
}
|
||||
peers = []peer{{ID: "client-1", Name: "客户端 1", PublicKey: service.Str(cfg, "client_public_key"), ClientAddress: address, AllowedIPs: service.Str(cfg, "allowed_ips"), Enabled: true, Managed: true}}
|
||||
}
|
||||
oldSecrets := parsePeerSecrets(secrets)
|
||||
if legacyPrivate := service.Str(secrets, "client_private_key"); legacyPrivate != "" {
|
||||
oldSecrets["client-1"] = peerSecret{PrivateKey: legacyPrivate, PresharedKey: service.Str(secrets, "preshared_key")}
|
||||
}
|
||||
seenIDs, seenKeys, seenAddresses := map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||
for i := range peers {
|
||||
p := &peers[i]
|
||||
if p.ID == "" {
|
||||
p.ID = fmt.Sprintf("client-%d", i+1)
|
||||
}
|
||||
if !peerIDPattern.MatchString(p.ID) {
|
||||
return nil, nil, fmt.Errorf("Peer ID %q 包含非法字符", p.ID)
|
||||
}
|
||||
if seenIDs[p.ID] {
|
||||
return nil, nil, fmt.Errorf("Peer ID 重复: %s", p.ID)
|
||||
}
|
||||
seenIDs[p.ID] = true
|
||||
if p.ClientAddress == "" || !validCIDR(p.ClientAddress) {
|
||||
return nil, nil, fmt.Errorf("Peer %s 的 client_address 必须是合法 CIDR", p.ID)
|
||||
}
|
||||
if seenAddresses[p.ClientAddress] {
|
||||
return nil, nil, fmt.Errorf("Peer client_address 重复: %s", p.ClientAddress)
|
||||
}
|
||||
seenAddresses[p.ClientAddress] = true
|
||||
if p.AllowedIPs == "" {
|
||||
p.AllowedIPs = service.StrOr(cfg, "allowed_ips", "0.0.0.0/0, ::/0")
|
||||
}
|
||||
if err := validateCIDRList(p.AllowedIPs); err != nil {
|
||||
return nil, nil, fmt.Errorf("Peer %s allowed_ips: %w", p.ID, err)
|
||||
}
|
||||
if strings.ContainsAny(p.Name, "\r\n") {
|
||||
return nil, nil, fmt.Errorf("Peer %s 的 name 不能包含换行符", p.ID)
|
||||
}
|
||||
sec := oldSecrets[p.ID]
|
||||
// An unchanged public key with a stored private key is still a platform
|
||||
// managed peer even if a client omitted the derived `managed` flag when
|
||||
// submitting an update. A changed public key deliberately becomes an
|
||||
// external peer and must not retain the old device private key/PSK.
|
||||
if p.PublicKey != "" && sec.PrivateKey != "" {
|
||||
existingPublic, err := publicKey(sec.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Peer %s: %w", p.ID, err)
|
||||
}
|
||||
if existingPublic == p.PublicKey {
|
||||
p.Managed = true
|
||||
} else {
|
||||
p.Managed = false
|
||||
sec.PrivateKey = ""
|
||||
sec.PresharedKey = ""
|
||||
}
|
||||
}
|
||||
if p.PublicKey == "" {
|
||||
if sec.PrivateKey != "" {
|
||||
pub, err := publicKey(sec.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Peer %s: %w", p.ID, err)
|
||||
}
|
||||
p.PublicKey, p.Managed = pub, true
|
||||
} else {
|
||||
priv, pub, err := genKey()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
p.PublicKey, sec.PrivateKey, p.Managed = pub, priv, true
|
||||
}
|
||||
}
|
||||
if !isWireGuardKey(p.PublicKey) {
|
||||
return nil, nil, fmt.Errorf("Peer %s 的 public_key 不是 WireGuard 公钥", p.ID)
|
||||
}
|
||||
if seenKeys[p.PublicKey] {
|
||||
return nil, nil, fmt.Errorf("Peer public_key 重复: %s", p.ID)
|
||||
}
|
||||
seenKeys[p.PublicKey] = true
|
||||
if sec.PresharedKey == "" {
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sec.PresharedKey = base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
if !isWireGuardKey(sec.PresharedKey) {
|
||||
return nil, nil, fmt.Errorf("Peer %s 的 preshared_key 格式非法", p.ID)
|
||||
}
|
||||
oldSecrets[p.ID] = sec
|
||||
}
|
||||
return peers, oldSecrets, nil
|
||||
}
|
||||
|
||||
func parsePeers(cfg map[string]interface{}) ([]peer, error) {
|
||||
raw, exists := cfg["peers"]
|
||||
if !exists || raw == nil {
|
||||
return nil, nil
|
||||
}
|
||||
items, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
// JSON retrieved from store uses []interface{}, but accepting []map makes
|
||||
// service-level callers and tests practical as well.
|
||||
if maps, mapsOK := raw.([]map[string]interface{}); mapsOK {
|
||||
items = make([]interface{}, len(maps))
|
||||
for i := range maps {
|
||||
items[i] = maps[i]
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("peers 必须是数组")
|
||||
}
|
||||
}
|
||||
peers := make([]peer, 0, len(items))
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("peers 项必须是对象")
|
||||
}
|
||||
p := peer{ID: service.Str(m, "id"), Name: service.Str(m, "name"), PublicKey: service.Str(m, "public_key"), ClientAddress: service.Str(m, "client_address"), AllowedIPs: service.Str(m, "allowed_ips"), Managed: service.Bool(m, "managed", false), Enabled: true}
|
||||
if _, set := m["enabled"]; set {
|
||||
p.Enabled = service.Bool(m, "enabled", true)
|
||||
}
|
||||
peers = append(peers, p)
|
||||
}
|
||||
return peers, nil
|
||||
}
|
||||
|
||||
func validatePeerInput(peers []peer) error {
|
||||
seenIDs, seenKeys, seenAddresses := map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||
for i := range peers {
|
||||
p := peers[i]
|
||||
if p.ID == "" {
|
||||
p.ID = fmt.Sprintf("client-%d", i+1)
|
||||
}
|
||||
if !peerIDPattern.MatchString(p.ID) {
|
||||
return fmt.Errorf("Peer ID %q 包含非法字符", p.ID)
|
||||
}
|
||||
if seenIDs[p.ID] {
|
||||
return fmt.Errorf("Peer ID 重复: %s", p.ID)
|
||||
}
|
||||
seenIDs[p.ID] = true
|
||||
if !validCIDR(p.ClientAddress) {
|
||||
return fmt.Errorf("Peer %s 的 client_address 必须是合法 CIDR", p.ID)
|
||||
}
|
||||
if seenAddresses[p.ClientAddress] {
|
||||
return fmt.Errorf("Peer client_address 重复: %s", p.ClientAddress)
|
||||
}
|
||||
seenAddresses[p.ClientAddress] = true
|
||||
if p.PublicKey != "" {
|
||||
if !isWireGuardKey(p.PublicKey) {
|
||||
return fmt.Errorf("Peer %s 的 public_key 不是 WireGuard 公钥", p.ID)
|
||||
}
|
||||
if seenKeys[p.PublicKey] {
|
||||
return fmt.Errorf("Peer public_key 重复: %s", p.ID)
|
||||
}
|
||||
seenKeys[p.PublicKey] = true
|
||||
}
|
||||
if p.AllowedIPs != "" {
|
||||
if err := validateCIDRList(p.AllowedIPs); err != nil {
|
||||
return fmt.Errorf("Peer %s allowed_ips: %w", p.ID, err)
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(p.Name, "\r\n") {
|
||||
return fmt.Errorf("Peer %s 的 name 不能包含换行符", p.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func peersToConfig(peers []peer) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
out = append(out, map[string]interface{}{"id": p.ID, "name": p.Name, "public_key": p.PublicKey, "client_address": p.ClientAddress, "allowed_ips": p.AllowedIPs, "enabled": p.Enabled, "managed": p.Managed})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parsePeerSecrets(secrets map[string]interface{}) map[string]peerSecret {
|
||||
out := map[string]peerSecret{}
|
||||
raw, ok := secrets["peer_secrets"].(map[string]interface{})
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
for id, value := range raw {
|
||||
m, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[id] = peerSecret{PrivateKey: service.Str(m, "private_key"), PresharedKey: service.Str(m, "preshared_key")}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func peerSecretsToConfig(secrets map[string]peerSecret) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(secrets))
|
||||
for id, sec := range secrets {
|
||||
out[id] = map[string]interface{}{"private_key": sec.PrivateKey, "preshared_key": sec.PresharedKey}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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 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)
|
||||
fmt.Fprintf(&b, "PostDown = iptables -D FORWARD -i %%i -j ACCEPT; iptables -D FORWARD -o %%i -j ACCEPT; iptables -t nat -D POSTROUTING -o %s -j MASQUERADE\n", egress)
|
||||
}
|
||||
for _, p := range peers {
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "\n[Peer]\n# %s (%s)\nPublicKey = %s\nPresharedKey = %s\nAllowedIPs = %s\n", p.Name, p.ID, p.PublicKey, secrets[p.ID].PresharedKey, p.ClientAddress)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func validCIDR(value string) bool {
|
||||
_, _, err := net.ParseCIDR(strings.TrimSpace(value))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func validateCIDRList(value string) error {
|
||||
for _, entry := range strings.Split(value, ",") {
|
||||
if !validCIDR(entry) {
|
||||
return fmt.Errorf("%q 不是 CIDR", strings.TrimSpace(entry))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"vpn-platform/backend/internal/model"
|
||||
"vpn-platform/backend/internal/store"
|
||||
)
|
||||
|
||||
func TestBuildPeersMigratesLegacySingleClient(t *testing.T) {
|
||||
cfg := map[string]interface{}{
|
||||
"address": "10.8.0.1/24",
|
||||
"client_address": "10.8.0.2/32",
|
||||
"allowed_ips": "0.0.0.0/0, ::/0",
|
||||
}
|
||||
peers, secrets, err := buildPeers(cfg, map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildPeers: %v", err)
|
||||
}
|
||||
if len(peers) != 1 || peers[0].ID != "client-1" || !peers[0].Managed {
|
||||
t.Fatalf("legacy peer migration = %#v", peers)
|
||||
}
|
||||
if !isWireGuardKey(peers[0].PublicKey) || !isWireGuardKey(secrets["client-1"].PrivateKey) || !isWireGuardKey(secrets["client-1"].PresharedKey) {
|
||||
t.Fatal("generated peer keys are not WireGuard keys")
|
||||
}
|
||||
cfg["server_public_key"] = "server-public-key"
|
||||
cfg["listen_port"] = 51820
|
||||
client, err := generateClientConfig(cfg, peers, secrets, "client-1")
|
||||
if err != nil {
|
||||
t.Fatalf("generateClientConfig: %v", err)
|
||||
}
|
||||
if !strings.Contains(client, "Address = 10.8.0.2/32") || !strings.Contains(client, "Endpoint = your-server-ip:51820") {
|
||||
t.Fatalf("unexpected client config:\n%s", client)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPeersKeepsExternalPeerPrivateKeyOutOfPlatform(t *testing.T) {
|
||||
_, externalPublic, err := genKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := map[string]interface{}{
|
||||
"allowed_ips": "10.0.0.0/8",
|
||||
"peers": []interface{}{map[string]interface{}{
|
||||
"id": "branch-office",
|
||||
"name": "Branch office",
|
||||
"public_key": externalPublic,
|
||||
"client_address": "10.8.0.10/32",
|
||||
"enabled": true,
|
||||
}},
|
||||
}
|
||||
peers, secrets, err := buildPeers(cfg, map[string]interface{}{})
|
||||
if err != nil {
|
||||
t.Fatalf("buildPeers: %v", err)
|
||||
}
|
||||
if peers[0].Managed || secrets["branch-office"].PrivateKey != "" {
|
||||
t.Fatalf("external peer must not gain a platform-held private key: %#v %#v", peers[0], secrets["branch-office"])
|
||||
}
|
||||
if _, err := generateClientConfig(cfg, peers, secrets, "branch-office"); err == nil {
|
||||
t.Fatal("external peer configuration should not be downloadable")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
_, err := s.Validate(map[string]interface{}{
|
||||
"address": "10.8.0.1/24",
|
||||
"peers": []interface{}{map[string]interface{}{"id": "bad", "client_address": "not-a-cidr"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("invalid peer address was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceNameFitsLinuxLimit(t *testing.T) {
|
||||
name := interfaceName(&model.Tunnel{ID: ^uint(0)})
|
||||
if len(name) > 15 {
|
||||
t.Fatalf("interface name %q exceeds IFNAMSIZ-1", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePersistsOnlyPublicPeerData(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
t.Setenv("VPN_DATA_DIR", dataDir)
|
||||
if err := store.Init(dataDir); err != nil {
|
||||
t.Fatalf("store.Init: %v", err)
|
||||
}
|
||||
tunnel := &model.Tunnel{
|
||||
Name: "wg-test",
|
||||
Protocol: "wireguard",
|
||||
Config: `{"address":"10.60.0.1/24","client_address":"10.60.0.2/32"}`,
|
||||
}
|
||||
if err := store.DB.Create(tunnel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := New().Create(tunnel); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if strings.Contains(tunnel.Config, "private_key") || strings.Contains(tunnel.Config, "preshared_key") {
|
||||
t.Fatalf("private material leaked into Config: %s", tunnel.Config)
|
||||
}
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(tunnel.Config), &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config["interface_name"] != interfaceName(tunnel) || int(config["listen_port"].(float64)) < defaultPort {
|
||||
t.Fatalf("missing interface/allocated port: %#v", config)
|
||||
}
|
||||
conf, err := New().GenerateClientConfig(tunnel)
|
||||
if err != nil || !strings.Contains(conf, "PrivateKey = ") {
|
||||
t.Fatalf("client configuration unavailable: %v\n%s", err, conf)
|
||||
}
|
||||
if _, err := os.Stat(serviceConfigPath(tunnel)); err != nil {
|
||||
t.Fatalf("server config not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func serviceConfigPath(tunnel *model.Tunnel) string {
|
||||
return os.Getenv("VPN_DATA_DIR") + "/instances/" + strconv.Itoa(int(tunnel.ID)) + "-" + tunnel.Protocol + "/" + interfaceName(tunnel) + ".conf"
|
||||
}
|
||||
Reference in new issue
Block a user