Files
opsx-vpn/backend/internal/service/openvpn/openvpn.go
T

515 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}