feat: implement VPN platform management and real protocol services

This commit is contained in:
Logan.Li committed 2026-07-22 21:49:13 +08:00
commit 58c036c34c
45 files changed
+9244

No files matched your search

+514
View File
@@ -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
}