// 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("\n" + ca + "\n") if cert != "" { b.WriteString("\n" + cert + "\n\n" + key + "\n") } b.WriteString("\n" + ta + "\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 }