f3cda67d9f
OpenVPN 2.7 Windows DCO passes username but NOT password as
environment variable with via-env mode. Switched to via-file:
server.conf: auth-user-pass-verify script via-file
verify.sh reads credentials from temp file ():
line 1 = username, line 2 = password
Also added pass_len to debug log so we can immediately see
if the password was actually received by the script.
713 lines
26 KiB
Go
713 lines
26 KiB
Go
package openvpn
|
||
|
||
import (
|
||
"bufio"
|
||
"crypto/x509"
|
||
"encoding/pem"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"openvpn-manager/internal/model"
|
||
)
|
||
|
||
// Manager 封装与本地 OpenVPN 的交互:生成 server.conf、生成证书、解析 status。
|
||
// 本服务不直接以 root 启动 OpenVPN 进程(避免安全/权限问题),而是在用户机器上
|
||
// 由 systemd / launchctl / 自定义脚本拉起。Manager 负责:
|
||
// 1. 生成易用的 server.conf / client.ovpn / ccd
|
||
// 2. 解析 openvpn --status 输出
|
||
// 3. 调用 openssl/easyrsa 完成证书签发与吊销
|
||
// 4. 提供 ping 命令检查进程可达性
|
||
type Manager struct {
|
||
binary string
|
||
dataDir string
|
||
pkiDir string
|
||
port int // manager HTTP port (for verify script callback)
|
||
}
|
||
|
||
func NewManager(binary, dataDir string) *Manager {
|
||
return &Manager{
|
||
binary: binary,
|
||
dataDir: dataDir,
|
||
pkiDir: filepath.Join(dataDir, "pki"),
|
||
port: 8089,
|
||
}
|
||
}
|
||
|
||
// SetPort 设置 manager 的 HTTP 端口,供 verify.sh 回调使用。
|
||
func (m *Manager) SetPort(port int) {
|
||
m.port = port
|
||
}
|
||
|
||
// PKIPath 返回实例的 PKI 目录(用于存放 issued/private 等子目录)。
|
||
func (m *Manager) PKIPath(instanceName string) string {
|
||
return filepath.Join(m.dataDir, "instances", instanceName, "pki")
|
||
}
|
||
|
||
// InstanceConf 返回实例 server.conf 路径。
|
||
func (m *Manager) InstanceConf(instanceName string) string {
|
||
return filepath.Join(m.dataDir, "instances", instanceName, "server.conf")
|
||
}
|
||
|
||
// EnsureCA 初始化全局 CA。幂等。
|
||
func (m *Manager) EnsureCA() error {
|
||
if _, err := os.Stat(filepath.Join(m.pkiDir, "ca.crt")); err == nil {
|
||
return nil
|
||
}
|
||
if err := os.MkdirAll(m.pkiDir, 0o700); err != nil {
|
||
return err
|
||
}
|
||
// 使用 openssl 直接生成自签 CA,避免依赖 easyrsa。
|
||
// 必须带 X.509 扩展 (basicConstraints + keyUsage),
|
||
// 否则 OpenVPN 2.7 / OpenSSL 3.x 会报 "Certificate does not have key usage extension"。
|
||
caExt := filepath.Join(m.pkiDir, "ca-ext.cnf")
|
||
if err := os.WriteFile(caExt, []byte(`[req]
|
||
distinguished_name = req_dn
|
||
prompt = no
|
||
[req_dn]
|
||
CN = OpenVPN-Manager-CA
|
||
[v3_ca]
|
||
basicConstraints = critical,CA:TRUE
|
||
keyUsage = critical,keyCertSign,cRLSign
|
||
subjectKeyIdentifier = hash
|
||
authorityKeyIdentifier = keyid:always,issuer
|
||
`), 0o600); err != nil {
|
||
return fmt.Errorf("write ca ext: %w", err)
|
||
}
|
||
if err := runShell(`openssl req -x509 -newkey rsa:2048 -nodes \
|
||
-keyout "`+m.pkiDir+`/ca.key" \
|
||
-out "`+m.pkiDir+`/ca.crt" \
|
||
-days 3650 -sha256 \
|
||
-subj "/CN=OpenVPN-Manager-CA" \
|
||
-extensions v3_ca -config "` + caExt + `" 2>/dev/null`); err != nil {
|
||
return fmt.Errorf("generate CA: %w", err)
|
||
}
|
||
// DH 参数:用 "dh none" 走 ECDHE,不再需要 dh.pem 文件。
|
||
// (保留目录以兼容旧配置)
|
||
// TLS-Auth key
|
||
if _, err := os.Stat(filepath.Join(m.pkiDir, "ta.key")); os.IsNotExist(err) {
|
||
if err := runShell(`openvpn --genkey secret "` + m.pkiDir + `/ta.key"`); err != nil {
|
||
return fmt.Errorf("generate ta.key: %w", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// WriteServerConf 生成 server.conf。
|
||
func (m *Manager) WriteServerConf(in *model.Instance, extraDir string) error {
|
||
dir := filepath.Dir(m.InstanceConf(in.Name))
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return err
|
||
}
|
||
conf := strings.Builder{}
|
||
conf.WriteString("# Generated by openvpn-manager at " + time.Now().Format(time.RFC3339) + "\n")
|
||
conf.WriteString("port " + strconv.Itoa(in.Port) + "\n")
|
||
conf.WriteString("proto " + in.Proto + "\n")
|
||
conf.WriteString("dev " + in.Dev + "\n")
|
||
conf.WriteString("ca " + filepath.Join(m.pkiDir, "ca.crt") + "\n")
|
||
conf.WriteString("cert " + filepath.Join(m.PKIPath(in.Name), "issued", "server.crt") + "\n")
|
||
conf.WriteString("key " + filepath.Join(m.PKIPath(in.Name), "private", "server.key") + "\n")
|
||
// 用 "dh none" 让 OpenVPN 走 ECDHE 密钥交换(OpenVPN 2.4+),
|
||
// 不需要 DH 参数文件,既安全又避免 openssl dhparam 2048 的长等待。
|
||
conf.WriteString("dh none\n")
|
||
conf.WriteString("tls-auth " + filepath.Join(m.pkiDir, "ta.key") + " 0\n")
|
||
conf.WriteString("topology subnet\n")
|
||
conf.WriteString("server " + cidrToServerDirective(in.Subnet) + "\n")
|
||
conf.WriteString("ifconfig-pool-persist " + filepath.Join(dir, "ipp.txt") + "\n")
|
||
conf.WriteString("keepalive 10 120\n")
|
||
conf.WriteString("persist-tun\n")
|
||
conf.WriteString("data-ciphers " + orDefault(in.Cipher, "AES-256-GCM") + ":AES-128-GCM\n")
|
||
conf.WriteString("status " + filepath.Join(dir, "status.log") + " 10\n")
|
||
conf.WriteString("status-version 3\n")
|
||
conf.WriteString("log " + filepath.Join(dir, "logs", "openvpn.log") + "\n")
|
||
conf.WriteString("verb 3\n")
|
||
conf.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n")
|
||
if in.PushDNS != "" {
|
||
for _, line := range strings.Split(in.PushDNS, "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
// 支持 "1.1.1.1 8.8.8.8" 或 "dhcp-option DNS 1.1.1.1" 两种写法
|
||
if strings.HasPrefix(line, "dhcp-option") {
|
||
conf.WriteString("push \"" + line + "\"\n")
|
||
} else {
|
||
for _, ip := range strings.Fields(line) {
|
||
conf.WriteString("push \"dhcp-option DNS " + ip + "\"\n")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if in.PushRoutes != "" {
|
||
for _, line := range strings.Split(in.PushRoutes, "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
conf.WriteString("push \"route " + line + "\"\n")
|
||
}
|
||
}
|
||
if extraDir != "" {
|
||
_ = os.MkdirAll(extraDir, 0o755)
|
||
conf.WriteString("client-config-dir " + extraDir + "\n")
|
||
}
|
||
if in.AccessMode == model.AccessWhitelist {
|
||
// 白名单模式:openVPN 在每次客户端连接时调用脚本应用 iptables
|
||
// 该脚本由 manager.AllowNetworksScript() 生成,负责:
|
||
// 1. 写入合并后的白名单到 ccd/<cn>(push route)
|
||
// 2. 在 FORWARD 链中按白名单过滤 tun->eth 的流量
|
||
conf.WriteString("client-connect " + filepath.Join(m.InstanceDir(in.Name), "client-connect.sh") + "\n")
|
||
conf.WriteString("client-disconnect " + filepath.Join(m.InstanceDir(in.Name), "client-disconnect.sh") + "\n")
|
||
// 强制客户端把流量全部走 VPN(否则白名单的 push route 没意义)
|
||
// 仍允许 bypass-dhcp 保留局域网 DNS
|
||
conf.WriteString("push \"redirect-gateway def1 bypass-dhcp\"\n")
|
||
}
|
||
if in.AuthMode == model.AuthCertPassword {
|
||
// 双因素认证:证书 + 用户名密码
|
||
// OpenVPN 以 via-file 方式调用 verify 脚本,$1 为临时文件路径:
|
||
// 第 1 行 = VPN用户名(=证书CN)
|
||
// 第 2 行 = 用户输入的明文密码
|
||
// 脚本退出码 0 = 允许,非 0 = 拒绝
|
||
verifyScript := filepath.Join(m.InstanceDir(in.Name), "verify.sh")
|
||
conf.WriteString("script-security 2\n")
|
||
conf.WriteString("auth-user-pass-verify \"" + verifyScript + "\" via-file\n")
|
||
conf.WriteString("verify-client-cert require\n")
|
||
_ = m.WriteVerifyScriptWithPort(in.Name, verifyScript, m.port)
|
||
}
|
||
if in.Extra != "" {
|
||
conf.WriteString("\n# --- custom ---\n")
|
||
conf.WriteString(in.Extra + "\n")
|
||
}
|
||
return os.WriteFile(m.InstanceConf(in.Name), []byte(conf.String()), 0o644)
|
||
}
|
||
|
||
// IssueCert 为客户端签发证书。clientName = CN。
|
||
// 返回 (certPath, keyPath, error)。
|
||
// 当 clientName == "server" 时签发带 serverAuth EKU 的服务端证书,
|
||
// 否则签发带 clientAuth EKU 的客户端证书。
|
||
func (m *Manager) IssueCert(instanceName, clientName string) (string, string, error) {
|
||
pki := m.PKIPath(instanceName)
|
||
issuedDir := filepath.Join(pki, "issued")
|
||
privDir := filepath.Join(pki, "private")
|
||
certsDir := filepath.Join(pki, "certs")
|
||
if err := os.MkdirAll(issuedDir, 0o755); err != nil {
|
||
return "", "", err
|
||
}
|
||
if err := os.MkdirAll(privDir, 0o700); err != nil {
|
||
return "", "", err
|
||
}
|
||
if err := os.MkdirAll(certsDir, 0o755); err != nil {
|
||
return "", "", err
|
||
}
|
||
crt := filepath.Join(issuedDir, clientName+".crt")
|
||
key := filepath.Join(privDir, clientName+".key")
|
||
csr := filepath.Join(pki, clientName+".csr")
|
||
// 已存在则跳过
|
||
if _, err := os.Stat(crt); err == nil {
|
||
return crt, key, nil
|
||
}
|
||
// 生成私钥
|
||
if err := runShell(fmt.Sprintf(`openssl genrsa -out "%s" 2048 2>/dev/null`, key)); err != nil {
|
||
return "", "", fmt.Errorf("gen key: %w", err)
|
||
}
|
||
// 生成 CSR
|
||
if err := runShell(fmt.Sprintf(`openssl req -new -key "%s" -out "%s" -subj "/CN=%s" 2>/dev/null`,
|
||
key, csr, clientName)); err != nil {
|
||
return "", "", fmt.Errorf("gen csr: %w", err)
|
||
}
|
||
// 写签发扩展配置(区分 server / client)
|
||
extFile := filepath.Join(pki, clientName+"-ext.cnf")
|
||
extContent := `[v3]
|
||
basicConstraints = critical,CA:FALSE
|
||
keyUsage = critical,digitalSignature,keyEncipherment
|
||
subjectKeyIdentifier = hash
|
||
authorityKeyIdentifier = keyid,issuer
|
||
`
|
||
if clientName == "server" {
|
||
extContent += "extendedKeyUsage = serverAuth\n"
|
||
} else {
|
||
extContent += "extendedKeyUsage = clientAuth\n"
|
||
}
|
||
if err := os.WriteFile(extFile, []byte(extContent), 0o600); err != nil {
|
||
return "", "", fmt.Errorf("write ext: %w", err)
|
||
}
|
||
// 用 CA 签发(带扩展)
|
||
caCrt := filepath.Join(m.pkiDir, "ca.crt")
|
||
caKey := filepath.Join(m.pkiDir, "ca.key")
|
||
if err := runShell(fmt.Sprintf(`openssl x509 -req -in "%s" -CA "%s" -CAkey "%s" -CAcreateserial \
|
||
-out "%s" -days 3650 -sha256 \
|
||
-extfile "%s" -extensions v3 2>/dev/null`,
|
||
csr, caCrt, caKey, crt, extFile)); err != nil {
|
||
return "", "", fmt.Errorf("sign cert: %w", err)
|
||
}
|
||
_ = os.Remove(csr)
|
||
return crt, key, nil
|
||
}
|
||
|
||
// IssueServerCert 为实例本身签发服务端证书。
|
||
func (m *Manager) IssueServerCert(instanceName string) error {
|
||
_, _, err := m.IssueCert(instanceName, "server")
|
||
return err
|
||
}
|
||
|
||
// RevokeCert 通过 CA 吊销证书(生成 CRL)。
|
||
// 由于完整 CRL 链路较重,本管理器使用更简单的"禁用"模型:保留吊销标记,
|
||
// 并通过 ccd 与 enabled=false 让 openvpn 拒绝连接。
|
||
func (m *Manager) RevokeCert(instanceName, clientName string) error {
|
||
_ = instanceName
|
||
_ = clientName
|
||
// 简化模型:不做 OpenSSL 吊销,仅通过 Store 标记 + 强制策略。
|
||
// 真正的吊销可通过 `openssl ca -revoke` 扩展。
|
||
return nil
|
||
}
|
||
|
||
// GenerateClientOVPN 生成 .ovpn 客户端配置,含内嵌证书便于分发。
|
||
func (m *Manager) GenerateClientOVPN(in *model.Instance, username, remoteHost string) (string, error) {
|
||
pki := m.PKIPath(in.Name)
|
||
crt, err := os.ReadFile(filepath.Join(pki, "issued", username+".crt"))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
key, err := os.ReadFile(filepath.Join(pki, "private", username+".key"))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
caCrt, err := os.ReadFile(filepath.Join(m.pkiDir, "ca.crt"))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
taKey, err := os.ReadFile(filepath.Join(m.pkiDir, "ta.key"))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if remoteHost == "" {
|
||
remoteHost = "vpn.example.com"
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString("client\n")
|
||
b.WriteString("dev " + in.Dev + "\n")
|
||
b.WriteString("proto " + in.Proto + "\n")
|
||
b.WriteString("remote " + remoteHost + " " + strconv.Itoa(in.Port) + "\n")
|
||
b.WriteString("resolv-retry infinite\n")
|
||
b.WriteString("nobind\n")
|
||
b.WriteString("persist-tun\n")
|
||
b.WriteString("data-ciphers " + orDefault(in.Cipher, "AES-256-GCM") + ":AES-128-GCM\n")
|
||
b.WriteString("auth " + orDefault(in.AuthDigest, "SHA256") + "\n")
|
||
b.WriteString("remote-cert-tls server\n")
|
||
b.WriteString("verb 3\n")
|
||
// 双因素模式:客户端需要输入用户名密码
|
||
if in.AuthMode == model.AuthCertPassword {
|
||
b.WriteString("auth-user-pass\n")
|
||
}
|
||
b.WriteString("\n<ca>\n")
|
||
b.WriteString(string(caCrt))
|
||
b.WriteString("\n</ca>\n")
|
||
b.WriteString("\n<cert>\n")
|
||
b.WriteString(string(crt))
|
||
b.WriteString("\n</cert>\n")
|
||
b.WriteString("\n<key>\n")
|
||
b.WriteString(string(key))
|
||
b.WriteString("\n</key>\n")
|
||
b.WriteString("\n<tls-auth>\n")
|
||
b.WriteString(string(taKey))
|
||
b.WriteString("\n</tls-auth>\n")
|
||
b.WriteString("key-direction 1\n")
|
||
out := filepath.Join(m.dataDir, "clients", in.Name, username+".ovpn")
|
||
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
|
||
return "", err
|
||
}
|
||
if err := os.WriteFile(out, []byte(b.String()), 0o600); err != nil {
|
||
return "", err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// GenerateClientOVPNFor 是基于 VPNUser + Instance 的便捷封装。
|
||
func (m *Manager) GenerateClientOVPNFor(u *model.VPNUser, in *model.Instance, remoteHost string) (string, error) {
|
||
return m.GenerateClientOVPN(in, u.Username, remoteHost)
|
||
}
|
||
|
||
// WriteCCD 写入客户端静态配置(固定 IP 等)。
|
||
func (m *Manager) WriteCCD(instanceName, username, body string) error {
|
||
dir := filepath.Join(m.dataDir, "instances", instanceName, "ccd")
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return err
|
||
}
|
||
return os.WriteFile(filepath.Join(dir, username), []byte(body+"\n"), 0o644)
|
||
}
|
||
|
||
// ValidateCIDR 校验 CIDR 是否合法(IP + /prefix 长度)。
|
||
func ValidateCIDR(s string) error {
|
||
ip, ipnet, err := net.ParseCIDR(strings.TrimSpace(s))
|
||
if err != nil {
|
||
return fmt.Errorf("invalid CIDR %q: %w", s, err)
|
||
}
|
||
if ip.To4() == nil && ip.To16() == nil {
|
||
return fmt.Errorf("invalid IP in CIDR %q", s)
|
||
}
|
||
_ = ipnet
|
||
return nil
|
||
}
|
||
|
||
// MergeAllowNetworks 合并实例级与用户级白名单,去重去空。
|
||
func MergeAllowNetworks(a, b []string) []string {
|
||
seen := map[string]bool{}
|
||
out := []string{}
|
||
for _, s := range append(append([]string{}, a...), b...) {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" || seen[s] {
|
||
continue
|
||
}
|
||
seen[s] = true
|
||
out = append(out, s)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// CIDRToRoute 将 CIDR 转为 OpenVPN push route 用的字符串(IP + Netmask)。
|
||
// 仅支持 IPv4。IPv6 用 /128 全 0 掩码(占位,本项目主要面向 IPv4)。
|
||
func CIDRToRoute(s string) (string, error) {
|
||
if err := ValidateCIDR(s); err != nil {
|
||
return "", err
|
||
}
|
||
_, ipnet, _ := net.ParseCIDR(strings.TrimSpace(s))
|
||
mask := ipnet.Mask
|
||
ones, _ := ipnet.Mask.Size()
|
||
// 转 4 字节点分十进制
|
||
if len(mask) == 4 {
|
||
return fmt.Sprintf("%s %d.%d.%d.%d", ipnet.IP.String(), mask[0], mask[1], mask[2], mask[3]), nil
|
||
}
|
||
// IPv6: 暂用 /128 占位,openVPN 走 route-ipv6 单独处理
|
||
_ = ones
|
||
return "", fmt.Errorf("IPv6 not yet supported: %s", s)
|
||
}
|
||
|
||
// AllowNetworksScript 生成 client-connect 脚本:
|
||
// - 输入: $1 = 连接上下文, $common_name=<CN>
|
||
// - 作用:读 ccd/<cn>,根据其白名单在 FORWARD 链插入 ACCEPT
|
||
//
|
||
// client-connect 脚本的 stdin 是 OpenVPN 提供的环境变量,我们打印
|
||
// "push route ..." 行让 openvpn 推给客户端。
|
||
func (m *Manager) AllowNetworksScript(instanceName string, nets []string) (string, error) {
|
||
dir := filepath.Join(m.dataDir, "instances", instanceName)
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return "", err
|
||
}
|
||
script := filepath.Join(dir, "client-connect.sh")
|
||
var b strings.Builder
|
||
b.WriteString("#!/bin/bash\n")
|
||
b.WriteString("# Generated by openvpn-manager at " + time.Now().Format(time.RFC3339) + "\n")
|
||
b.WriteString("set -e\n\n")
|
||
b.WriteString("# OpenVPN 客户端连接脚本: 接受 ccd 之外额外的 push 指令\n")
|
||
b.WriteString("# 我们使用 stdin 提供的环境变量(由 OpenVPN 注入),按白名单允许的网段\n")
|
||
b.WriteString("# 写入 FORWARD 链,然后通过 echo 'push \"route ...\"' 回传给 OpenVPN。\n\n")
|
||
b.WriteString("CN=\"${common_name:-}\"\n")
|
||
b.WriteString("VPN_IP=\"${ifconfig_pool_remote_ip:-${trusted_ip:-}}\"\n\n")
|
||
b.WriteString("# 仅处理已启用的用户:从 ccd/<cn> 读取 allow_networks(若文件不存在则拒绝推送任何路由)\n")
|
||
b.WriteString("CCD_FILE=\"$(dirname \"$0\")/ccd/${CN}\"\n")
|
||
b.WriteString("if [[ ! -f \"$CCD_FILE\" ]]; then\n")
|
||
b.WriteString(" echo \"# ccd file missing for $CN, denying\" >&2\n")
|
||
b.WriteString(" exit 1\n")
|
||
b.WriteString("fi\n\n")
|
||
b.WriteString("# 解析 allow_networks(grep + awk)\n")
|
||
b.WriteString("split_cidr() {\n")
|
||
b.WriteString(" local cidr=\"$1\"\n")
|
||
b.WriteString(" local ip mask\n")
|
||
b.WriteString(" ip=\"${cidr%/*}\"\n")
|
||
b.WriteString(" local prefix=\"${cidr##*/}\"\n")
|
||
b.WriteString(" case \"$prefix\" in\n")
|
||
b.WriteString(" 32) mask=\"255.255.255.255\" ;;\n")
|
||
b.WriteString(" 31) mask=\"255.255.255.254\" ;;\n")
|
||
b.WriteString(" 30) mask=\"255.255.255.252\" ;;\n")
|
||
b.WriteString(" 29) mask=\"255.255.255.248\" ;;\n")
|
||
b.WriteString(" 28) mask=\"255.255.255.240\" ;;\n")
|
||
b.WriteString(" 27) mask=\"255.255.255.224\" ;;\n")
|
||
b.WriteString(" 26) mask=\"255.255.255.192\" ;;\n")
|
||
b.WriteString(" 25) mask=\"255.255.255.128\" ;;\n")
|
||
b.WriteString(" 24) mask=\"255.255.255.0\" ;;\n")
|
||
b.WriteString(" 23) mask=\"255.255.254.0\" ;;\n")
|
||
b.WriteString(" 22) mask=\"255.255.252.0\" ;;\n")
|
||
b.WriteString(" 21) mask=\"255.255.248.0\" ;;\n")
|
||
b.WriteString(" 20) mask=\"255.255.240.0\" ;;\n")
|
||
b.WriteString(" 19) mask=\"255.255.224.0\" ;;\n")
|
||
b.WriteString(" 18) mask=\"255.255.192.0\" ;;\n")
|
||
b.WriteString(" 17) mask=\"255.255.128.0\" ;;\n")
|
||
b.WriteString(" 16) mask=\"255.255.0.0\" ;;\n")
|
||
b.WriteString(" 15) mask=\"255.254.0.0\" ;;\n")
|
||
b.WriteString(" 14) mask=\"255.252.0.0\" ;;\n")
|
||
b.WriteString(" 13) mask=\"255.248.0.0\" ;;\n")
|
||
b.WriteString(" 12) mask=\"255.240.0.0\" ;;\n")
|
||
b.WriteString(" 11) mask=\"255.224.0.0\" ;;\n")
|
||
b.WriteString(" 10) mask=\"255.192.0.0\" ;;\n")
|
||
b.WriteString(" 9) mask=\"255.128.0.0\" ;;\n")
|
||
b.WriteString(" 8) mask=\"255.0.0.0\" ;;\n")
|
||
b.WriteString(" *) echo \"unsupported prefix /$prefix for $cidr\" >&2; return 1 ;;\n")
|
||
b.WriteString(" esac\n")
|
||
b.WriteString(" echo \"$ip $mask\"\n")
|
||
b.WriteString("}\n\n")
|
||
b.WriteString("while IFS= read -r net; do\n")
|
||
b.WriteString(" [[ -z \"$net\" || \"$net\" =~ ^# ]] && continue\n")
|
||
b.WriteString(" pair=\"$(split_cidr \"$net\")\" || continue\n")
|
||
b.WriteString(" echo \"push \\\"route ${pair}\\\"\"\n")
|
||
b.WriteString("done < <(grep -E '^[0-9]' \"$CCD_FILE\" || true)\n\n")
|
||
b.WriteString("# iptables:放行此客户端 VPN_IP -> 白名单网段的 FORWARD\n")
|
||
b.WriteString("if [[ -n \"$VPN_IP\" ]]; then\n")
|
||
b.WriteString(" while IFS= read -r net; do\n")
|
||
b.WriteString(" [[ -z \"$net\" || \"$net\" =~ ^# ]] && continue\n")
|
||
b.WriteString(" /sbin/iptables -I FORWARD -s \"$VPN_IP\" -d \"$net\" -j ACCEPT 2>/dev/null || true\n")
|
||
b.WriteString(" /sbin/iptables -I FORWARD -d \"$VPN_IP\" -s \"$net\" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true\n")
|
||
b.WriteString(" done < <(grep -E '^[0-9]' \"$CCD_FILE\" || true)\n")
|
||
b.WriteString("fi\n\n")
|
||
b.WriteString("exit 0\n")
|
||
if err := os.WriteFile(script, []byte(b.String()), 0o755); err != nil {
|
||
return "", err
|
||
}
|
||
// 同样的逻辑写到 disconnect 脚本:清理 iptables 规则
|
||
dis := filepath.Join(dir, "client-disconnect.sh")
|
||
var d strings.Builder
|
||
d.WriteString("#!/bin/bash\n")
|
||
d.WriteString("# openvpn-manager generated\n")
|
||
d.WriteString("VPN_IP=\"${ifconfig_pool_remote_ip:-${trusted_ip:-}}\"\n")
|
||
d.WriteString("CN=\"${common_name:-}\"\n")
|
||
d.WriteString("if [[ -n \"$VPN_IP\" ]]; then\n")
|
||
d.WriteString(" /sbin/iptables -D FORWARD -s \"$VPN_IP\" -j ACCEPT 2>/dev/null || true\n")
|
||
d.WriteString(" /sbin/iptables -D FORWARD -d \"$VPN_IP\" -m state --state ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || true\n")
|
||
d.WriteString("fi\n")
|
||
d.WriteString("exit 0\n")
|
||
if err := os.WriteFile(dis, []byte(d.String()), 0o755); err != nil {
|
||
return "", err
|
||
}
|
||
return script, nil
|
||
}
|
||
|
||
// InstanceDir 返回实例的根目录(供脚本路径使用)。
|
||
func (m *Manager) InstanceDir(name string) string {
|
||
return filepath.Join(m.dataDir, "instances", name)
|
||
}
|
||
|
||
// WriteVerifyScript 生成 auth-user-pass-verify 认证脚本。
|
||
// OpenVPN 以 via-env 方式调用此脚本:
|
||
// 环境变量 username = 证书 CN
|
||
// 环境变量 password = 用户输入的明文密码
|
||
//
|
||
// 脚本通过 curl 调用 manager 的 /api/vpn/verify 接口验证密码,
|
||
// manager 用 Go bcrypt 库验证,无需 Python 模块或 htpasswd。
|
||
func (m *Manager) WriteVerifyScript(instanceName, scriptPath string) error {
|
||
// 内嵌占位符,实际端口由 WriteServerConf 调用前从 config 获取
|
||
return m.WriteVerifyScriptWithPort(instanceName, scriptPath, 8089)
|
||
}
|
||
|
||
// WriteVerifyScriptWithPort 生成带指定端口的认证脚本。
|
||
func (m *Manager) WriteVerifyScriptWithPort(instanceName, scriptPath string, port int) error {
|
||
script := fmt.Sprintf(`#!/bin/bash
|
||
# OpenVPN auth-user-pass-verify script (via-file).
|
||
# Generated by openvpn-manager — do NOT edit manually.
|
||
# OpenVPN writes credentials to a temp file passed as $1:
|
||
# line 1 = username, line 2 = password
|
||
LOG="/tmp/openvpn-verify.log"
|
||
PORT=%d
|
||
INSTANCE="%s"
|
||
CREDS_FILE="$1"
|
||
|
||
echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') VERIFY instance=$INSTANCE creds_file=$CREDS_FILE" >> "$LOG"
|
||
|
||
if [ -z "$CREDS_FILE" ] || [ ! -f "$CREDS_FILE" ]; then
|
||
echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') DENY no credentials file" >> "$LOG"
|
||
exit 1
|
||
fi
|
||
|
||
user=$(sed -n '1p' "$CREDS_FILE")
|
||
pass=$(sed -n '2p' "$CREDS_FILE")
|
||
echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') user=$user pass_len=${#pass}" >> "$LOG"
|
||
|
||
if [ -z "$user" ] || [ -z "$pass" ]; then
|
||
echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') DENY empty credentials" >> "$LOG"
|
||
exit 1
|
||
fi
|
||
|
||
CODE=$(curl -s -o /tmp/ovpn-verify-resp.txt -w "%%{http_code}" \
|
||
--connect-timeout 3 --max-time 5 \
|
||
-X POST "http://127.0.0.1:${PORT}/api/vpn/verify" \
|
||
-H "Content-Type: application/json" \
|
||
-d "{\"instance\":\"${INSTANCE}\",\"username\":\"${user}\",\"password\":\"${pass}\"}" \
|
||
2>>"$LOG" || echo "000")
|
||
BODY=$(cat /tmp/ovpn-verify-resp.txt 2>/dev/null)
|
||
|
||
echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') HTTP=$CODE BODY=$BODY" >> "$LOG"
|
||
|
||
if [ "$CODE" = "200" ]; then
|
||
echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') ALLOW" >> "$LOG"
|
||
exit 0
|
||
else
|
||
echo "$(date '+%%Y-%%m-%%d %%H:%%M:%%S') DENY" >> "$LOG"
|
||
exit 1
|
||
fi
|
||
`, port, instanceName)
|
||
if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil {
|
||
return err
|
||
}
|
||
return os.WriteFile(scriptPath, []byte(script), 0o755)
|
||
}
|
||
|
||
// SetIptablesForInstance 应用一个实例的默认 FORWARD 策略。
|
||
// 在白名单模式下,我们希望"默认拒绝 tun->eth 的转发",再由 client-connect 脚本按需放行。
|
||
// 这个函数在实例启动时调用一次。
|
||
//
|
||
// 注意: iptables 修改宿主机的防火墙,需要 root 权限。脚本会以 noop 模式尝试运行,
|
||
// 失败仅记录日志,不会阻塞实例启动(允许开发环境无 iptables)。
|
||
func (m *Manager) SetIptablesForInstance(in *model.Instance) error {
|
||
if in.AccessMode != model.AccessWhitelist {
|
||
return nil
|
||
}
|
||
tunName := in.Dev // tun0 / tun1 ...
|
||
if in.Dev == "tap" {
|
||
tunName = "tap0"
|
||
}
|
||
// 默认策略:FORWARD 链中,凡是从 tun 设备出去的,如果未命中 ACCEPT 一律 DROP
|
||
// 这条规则加到 FORWARD 链末尾,优先级低于 client-connect 插入的 ACCEPT
|
||
cmd := exec.Command("/sbin/iptables", "-A", "FORWARD", "-i", tunName, "-j", "REJECT",
|
||
"--reject-with", "icmp-net-unreachable")
|
||
out, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
// 不致命:开发机可能没装 iptables 或没权限
|
||
return fmt.Errorf("iptables: %v: %s", err, strings.TrimSpace(string(out)))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// DeleteCCD 移除 ccd 文件。
|
||
func (m *Manager) DeleteCCD(instanceName, username string) error {
|
||
p := filepath.Join(m.dataDir, "instances", instanceName, "ccd", username)
|
||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ParseStatus 解析 openvpn --status 输出。
|
||
// 输入是 status-version 3 的文本,格式详见 OpenVPN 文档。
|
||
func (m *Manager) ParseStatus(path string) ([]StatusEntry, error) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer f.Close()
|
||
return ParseStatusReader(f)
|
||
}
|
||
|
||
// StatusEntry 是 status 文件中的一条 CLIENT 记录。
|
||
type StatusEntry struct {
|
||
CommonName string
|
||
RealAddress string // IP:port
|
||
VPNAddress string // 客户端 VPN 内网 IP
|
||
BytesRecv int64
|
||
BytesSent int64
|
||
ConnectedAt time.Time
|
||
}
|
||
|
||
var (
|
||
reHdr = regexp.MustCompile(`^Updated,([^,]+),`)
|
||
reCli = regexp.MustCompile(`^CLIENT_LIST,([^,]+),([^,]+),([^,]+),(\d+),(\d+),`)
|
||
reTime = regexp.MustCompile(`^Connected Since,([^,]+),`)
|
||
)
|
||
|
||
func ParseStatusReader(r io.Reader) ([]StatusEntry, error) {
|
||
sc := bufio.NewScanner(r)
|
||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||
var out []StatusEntry
|
||
for sc.Scan() {
|
||
line := sc.Text()
|
||
switch {
|
||
case strings.HasPrefix(line, "CLIENT_LIST,"):
|
||
m := reCli.FindStringSubmatch(line)
|
||
if m == nil {
|
||
continue
|
||
}
|
||
connected, _ := time.Parse("Mon Jan 2 15:04:05 2006", m[2])
|
||
out = append(out, StatusEntry{
|
||
CommonName: m[1],
|
||
RealAddress: m[3],
|
||
VPNAddress: m[4],
|
||
BytesRecv: atoi64(m[5]),
|
||
BytesSent: atoi64(m[6]),
|
||
ConnectedAt: connected,
|
||
})
|
||
}
|
||
}
|
||
return out, sc.Err()
|
||
}
|
||
|
||
// IsRunning 通过 TCP 探测 openvpn 端口是否可连,仅供参考。
|
||
func (m *Manager) IsRunning(host string, port int) bool {
|
||
addr := fmt.Sprintf("%s:%d", host, port)
|
||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
_ = conn.Close()
|
||
return true
|
||
}
|
||
|
||
// CertNotAfter 解析证书的过期时间。
|
||
func (m *Manager) CertNotAfter(certPath string) (time.Time, error) {
|
||
b, err := os.ReadFile(certPath)
|
||
if err != nil {
|
||
return time.Time{}, err
|
||
}
|
||
block, _ := pem.Decode(b)
|
||
if block == nil {
|
||
return time.Time{}, fmt.Errorf("not a pem file")
|
||
}
|
||
c, err := x509.ParseCertificate(block.Bytes)
|
||
if err != nil {
|
||
return time.Time{}, err
|
||
}
|
||
return c.NotAfter, nil
|
||
}
|
||
|
||
// ----- helpers -----
|
||
|
||
func runShell(s string) error {
|
||
cmd := exec.Command("bash", "-c", s)
|
||
cmd.Stdout = os.Stdout
|
||
cmd.Stderr = os.Stderr
|
||
return cmd.Run()
|
||
}
|
||
|
||
func orDefault(s, def string) string {
|
||
if strings.TrimSpace(s) == "" {
|
||
return def
|
||
}
|
||
return s
|
||
}
|
||
|
||
func atoi64(s string) int64 {
|
||
n, _ := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||
return n
|
||
}
|
||
|
||
// cidrToServerDirective 把 "10.8.0.0/24" 转成 OpenVPN server 指令需要的
|
||
// "10.8.0.0 255.255.255.0" 格式(网络地址 + 点分掩码)。
|
||
// 如果输入不含 "/"(已经是 "ip mask" 形式),原样返回。
|
||
func cidrToServerDirective(subnet string) string {
|
||
subnet = strings.TrimSpace(subnet)
|
||
if !strings.Contains(subnet, "/") {
|
||
return subnet
|
||
}
|
||
_, ipnet, err := net.ParseCIDR(subnet)
|
||
if err != nil {
|
||
return subnet // 让 OpenVPN 报错,总比生成错误格式好
|
||
}
|
||
return ipnet.IP.String() + " " + net.IP(ipnet.Mask).String()
|
||
}
|
||
|
||
func pemTrim(b []byte) string {
|
||
return strings.TrimSpace(string(b))
|
||
} |