Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5519c2f6c | |||
| 2b0f144abc | |||
| 5725149546 | |||
| 9054da954a | |||
| 9f24c74527 | |||
| 1706552b33 | |||
| 81544d2335 | |||
| 19c0b190ea |
@@ -23,9 +23,10 @@
|
||||
- [二、首次配置](#二首次配置)
|
||||
- [登录 Web 控制台](#登录-web-控制台)
|
||||
- [修改默认密码](#修改默认密码)
|
||||
- [创建第一个实例](#创建第一个实例)
|
||||
- [创建客户端用户并下载配置](#创建客户端用户并下载配置)
|
||||
- [防火墙与公网暴露](#防火墙与公网暴露)
|
||||
| - [创建第一个实例](#创建第一个实例)
|
||||
- [创建客户端用户并下载配置](#创建客户端用户并下载配置)
|
||||
- [IP 转发与内网访问](#ip-转发与内网访问客户端访问服务端-lan)
|
||||
- [防火墙与公网暴露](#防火墙与公网暴露)
|
||||
- [三、访问控制(白名单模式)](#三访问控制白名单模式)
|
||||
- [四、客户端使用](#四客户端使用)
|
||||
- [配置文件说明](#配置文件说明)
|
||||
@@ -58,6 +59,7 @@
|
||||
| 一键恢复 | 上传/选择已有备份,直接覆盖还原 |
|
||||
| 操作审计 | 所有写操作(创建/修改/删除/吊销/备份)持久化,记录操作者/IP/结果 |
|
||||
| JWT 鉴权 | 登录后 12 小时有效 token,无状态可水平扩展 |
|
||||
| **内网转发** | 文档化 IP 转发 + NAT 一键配置,客户端可访问服务端 LAN |
|
||||
|
||||
## 架构概览
|
||||
|
||||
@@ -369,6 +371,127 @@ Web → "用户管理":
|
||||
|
||||
直接发给用户即可,无需额外的 `ca.crt` 等文件。
|
||||
|
||||
## IP 转发与内网访问(客户端访问服务端 LAN)
|
||||
|
||||
**场景**: 客户端连上 VPN 后,想访问服务端所在的局域网设备(如 `172.16.2.0/24` 网段的服务器、打印机、NAS 等)。
|
||||
|
||||
**前提**: 客户端能 ping 通 VPN 网关(`10.8.0.1`),但访问不了 LAN(如 `ping 172.16.2.30` 失败)。
|
||||
|
||||
OpenVPN 默认**只让客户端访问 VPN 子网本身**,不能直接访问服务端 LAN。要打通,服务端需要做两件事:
|
||||
|
||||
1. **开启 IP 转发**(`net.ipv4.ip_forward=1`),让内核把 VPN 客户端的包从 tun 设备转到 LAN 网卡。
|
||||
2. **配置 NAT(MASQUERADE)**,让 VPN 客户端的源 IP 在离开服务端时被替换成 LAN 网卡 IP,这样 LAN 设备知道怎么回包。
|
||||
|
||||
### 一键启用(推荐)
|
||||
|
||||
如果服务端只有一个网卡接 LAN(典型场景),执行:
|
||||
|
||||
```bash
|
||||
# 1. 立即启用 IP 转发
|
||||
sudo sysctl -w net.ipv4.ip_forward=1
|
||||
|
||||
# 2. 持久化(重启不丢)
|
||||
echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-openvpn-manager.conf
|
||||
|
||||
# 3. 添加 NAT 规则
|
||||
# eth0 是服务端接 LAN 的网卡名(可能是 ens192/eno1/enp0s3 等,用 ip a 查看)
|
||||
LAN_IF="eth0" # ← 改成实际网卡名
|
||||
sudo iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o "$LAN_IF" -j MASQUERADE
|
||||
sudo iptables -A FORWARD -i "$LAN_IF" -o tun+ -m state --state RELATED,ESTABLISHED -j ACCEPT
|
||||
sudo iptables -A FORWARD -i tun+ -o "$LAN_IF" -j ACCEPT
|
||||
|
||||
# 4. 持久化 iptables(重启不丢)
|
||||
sudo apt-get install -y iptables-persistent # Debian/Ubuntu
|
||||
sudo netfilter-persistent save # 保存当前规则
|
||||
# 或: sudo service iptables save # CentOS/RHEL
|
||||
```
|
||||
|
||||
> **为什么是 `tun+`?**: OpenVPN 用 `tun0/tun1...` 设备,`+` 通配匹配所有 tun 设备。多个实例并存也能用。
|
||||
|
||||
### 验证
|
||||
|
||||
```bash
|
||||
# 服务端视角: 应该看到一条 MASQUERADE 规则
|
||||
sudo iptables -t nat -L POSTROUTING -n -v | grep MASQUERADE
|
||||
|
||||
# 客户端视角: VPN 连接后
|
||||
ping 10.8.0.1 # 必通(网关)
|
||||
ping <服务端 LAN 上的某个 IP> # 通了说明成功
|
||||
curl ifconfig.me # 显示服务端公网 IP,说明 NAT 生效
|
||||
```
|
||||
|
||||
### 找对 LAN 网卡
|
||||
|
||||
```bash
|
||||
ip -br a # 简略列出所有网卡和 IP
|
||||
ip route | grep default # 看默认路由走哪块网卡 — 通常就是 LAN 网卡
|
||||
```
|
||||
|
||||
例:
|
||||
```
|
||||
default via 172.16.2.254 dev ens192 → LAN 网卡是 ens192
|
||||
```
|
||||
|
||||
### push 路由(让客户端知道去 LAN 怎么走)
|
||||
|
||||
光在服务端做 NAT 不够,还得让**客户端知道** "要去 `172.16.2.0/24` 就走 VPN"。两种做法:
|
||||
|
||||
**方法 A:在实例里 push 路由(推荐)**
|
||||
|
||||
编辑实例的 `server.conf`,添加:
|
||||
```
|
||||
push "route 172.16.2.0 255.255.255.0"
|
||||
```
|
||||
然后在 Web 面板重启实例。
|
||||
|
||||
**方法 B:在 Web 面板"实例管理"里配置**
|
||||
|
||||
把 LAN 网段加到实例的 **AccessMode** 白名单或 **push 路由列表**。白名单模式下 `allow_networks` 会自动生成对应的 push 路由。
|
||||
|
||||
### 多网卡/复杂路由场景
|
||||
|
||||
如果服务端有**多块 LAN 网卡**(如同时接公司网和家庭网),MASQUERADE 规则要分别添加:
|
||||
|
||||
```bash
|
||||
for IF in ens192 ens224; do
|
||||
sudo iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o "$IF" -j MASQUERADE
|
||||
done
|
||||
```
|
||||
|
||||
或者直接用 `-o eth+` 一次性匹配所有以太网卡(不太安全,慎用):
|
||||
```bash
|
||||
sudo iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -j MASQUERADE
|
||||
```
|
||||
|
||||
### 排障
|
||||
|
||||
| 症状 | 排查 |
|
||||
| ---- | ---- |
|
||||
| 客户端能 ping 通 10.8.0.1,ping 不通 LAN | 没开 IP 转发,或没加 MASQUERADE 规则 |
|
||||
| 能 ping 通 LAN IP,但 SSH/服务不通 | LAN 设备防火墙拒绝 VPN 子网;在 LAN 设备上 `iptables -I INPUT -s 10.8.0.0/24 -j ACCEPT` |
|
||||
| 重启后规则丢失 | 没装 iptables-persistent 或没保存;参考上文持久化步骤 |
|
||||
| ping 得通但访问慢/丢包 | MTU 问题;在 server.conf 加 `tun-mtu 1400 mssfix 1360` |
|
||||
| ip_forward 已开但客户端仍上不了网 | push 路由缺失;客户端路由表里没有目标 LAN 网段 |
|
||||
|
||||
### 检查清单
|
||||
|
||||
```bash
|
||||
# 1. 转发开关
|
||||
cat /proc/sys/net/ipv4/ip_forward # 必须输出 1
|
||||
|
||||
# 2. NAT 规则
|
||||
sudo iptables -t nat -L POSTROUTING -n # 应该有 10.8.0.0/24 的 MASQUERADE
|
||||
|
||||
# 3. FORWARD 策略(默认 ACCEPT 还是 DROP?)
|
||||
sudo iptables -L FORWARD # 看到 policy DROP 就需要显式 ACCEPT tun+ ↔ LAN 的包
|
||||
|
||||
# 4. 客户端路由
|
||||
# VPN 连接后,在客户端执行:
|
||||
ip route # 应该看到 "10.8.0.0/24 dev tun0" 和 "172.16.2.0/24 via 10.8.0.1 dev tun0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 防火墙与公网暴露
|
||||
|
||||
1. Web 管理端口(默认 8089)**不建议直接暴露公网**,建议:
|
||||
@@ -647,8 +770,9 @@ sudo dnf install -y NetworkManager-openvpn NetworkManager-openvpn-gnome # F
|
||||
| 症状 | 可能原因 |
|
||||
| ---- | -------- |
|
||||
| 连接后立刻断开 | 客户端证书与 CA 不匹配;服务端证书过期 |
|
||||
| 拿到 IP 但 ping 不通服务端 | 防火墙没允许 UDP 1194;服务端没启用 IP 转发 |
|
||||
| 拿到 IP 但 ping 不通服务端 | 防火墙没允许 UDP 1194;服务端没启用 IP 转发(见 [IP 转发与内网访问](#ip-转发与内网访问客户端访问服务端-lan)) |
|
||||
| 拿到 IP 但访问不了互联网 | 没推送 DNS,或客户端没把 VPN 设为默认网关 |
|
||||
| 拿到 IP 能 ping 通网关,访问不了 LAN | **没做服务端 NAT** — 见 [IP 转发与内网访问](#ip-转发与内网访问客户端访问服务端-lan) |
|
||||
| Android 连不上 | 服务器在 NAT 后,检查运营商是否屏蔽 UDP |
|
||||
| iOS 连不上 | 看 OpenVPN 日志(应用内 OpenVPN → Settings → Log) |
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ func main() {
|
||||
log.Printf("warn: ensure CA: %v", err)
|
||||
}
|
||||
svc := service.New(cfg, st, ovm)
|
||||
svc.StartStatusSync() // 后台定时同步连接日志
|
||||
|
||||
addr := cfg.Host + ":" + itoa(cfg.Port)
|
||||
log.Printf("openvpn-manager listening on %s, data=%s, dist=%s", addr, cfg.DataDir, *distDir)
|
||||
|
||||
@@ -217,6 +217,33 @@ func (s *Server) dashboard(c *gin.Context) {
|
||||
"allow_networks": in.AllowNetworks,
|
||||
})
|
||||
}
|
||||
// 实时连接日志:从 status.log 读取在线用户
|
||||
var recentConnLogs []model.ConnectionLog
|
||||
for _, in := range instances {
|
||||
statusPath := filepath.Join(s.Svc.Cfg.InstanceDir(in.Name), "status.log")
|
||||
entries, _ := s.Svc.Ovm.ParseStatus(statusPath)
|
||||
for _, e := range entries {
|
||||
recentConnLogs = append(recentConnLogs, model.ConnectionLog{
|
||||
InstanceID: in.ID, CommonName: e.CommonName,
|
||||
RealIP: e.RealAddress, VPNIP: e.VPNAddress,
|
||||
BytesIn: e.BytesRecv, BytesOut: e.BytesSent,
|
||||
ConnectedAt: e.ConnectedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
// 补充历史已断开的
|
||||
history := s.Svc.Store.ListConnLogs("", 20)
|
||||
liveSet := map[string]bool{}
|
||||
for _, l := range recentConnLogs {
|
||||
liveSet[l.CommonName] = true
|
||||
}
|
||||
for _, h := range history {
|
||||
if h.DisconnectedAt == nil && liveSet[h.CommonName] {
|
||||
continue
|
||||
}
|
||||
recentConnLogs = append(recentConnLogs, h)
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"instances": len(instances),
|
||||
"running": countByStatus(instances, "running"),
|
||||
@@ -226,7 +253,7 @@ func (s *Server) dashboard(c *gin.Context) {
|
||||
"online": online,
|
||||
"expiring_certs": expiring,
|
||||
"recent_audits": s.Svc.Store.ListAudits(20),
|
||||
"recent_conn_logs": s.Svc.Store.ListConnLogs("", 20),
|
||||
"recent_conn_logs": recentConnLogs,
|
||||
"access_overview": overview,
|
||||
})
|
||||
}
|
||||
@@ -465,9 +492,67 @@ func (s *Server) listCerts(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ---- conn logs ----
|
||||
|
||||
func (s *Server) listConnLogs(c *gin.Context) {
|
||||
c.JSON(200, s.Svc.Store.ListConnLogs(c.Query("instance"), 200))
|
||||
// 诊断信息:返回每个实例 status.log 的存在情况和原始内容前 200 字节
|
||||
instanceID := c.Query("instance")
|
||||
|
||||
// 1) 数据库里的历史日志(有断开时间的老连接)
|
||||
history := s.Svc.Store.ListConnLogs(instanceID, 200)
|
||||
|
||||
// 2) 实时解析所有实例的 status.log,生成当前在线用户
|
||||
var live []model.ConnectionLog
|
||||
var diag []map[string]any
|
||||
instances := s.Svc.Store.ListInstances()
|
||||
for _, in := range instances {
|
||||
if instanceID != "" && instanceID != in.ID {
|
||||
continue
|
||||
}
|
||||
statusPath := filepath.Join(s.Svc.Cfg.InstanceDir(in.Name), "status.log")
|
||||
fi, statErr := os.Stat(statusPath)
|
||||
d := map[string]any{
|
||||
"instance": in.Name, "path": statusPath,
|
||||
"exists": fi != nil, "size": int64(0),
|
||||
}
|
||||
if statErr == nil {
|
||||
d["size"] = fi.Size()
|
||||
}
|
||||
entries, err := s.Svc.Ovm.ParseStatus(statusPath)
|
||||
d["parsed"] = len(entries)
|
||||
if err != nil {
|
||||
d["error"] = err.Error()
|
||||
}
|
||||
diag = append(diag, d)
|
||||
for _, e := range entries {
|
||||
live = append(live, model.ConnectionLog{
|
||||
InstanceID: in.ID,
|
||||
CommonName: e.CommonName,
|
||||
RealIP: e.RealAddress,
|
||||
VPNIP: e.VPNAddress,
|
||||
BytesIn: e.BytesRecv,
|
||||
BytesOut: e.BytesSent,
|
||||
ConnectedAt: e.ConnectedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 合并:先输出在线的,再输出历史的(去重:跳过 history 中与 live CN 重复的未断开记录)
|
||||
liveSet := map[string]bool{}
|
||||
for _, l := range live {
|
||||
liveSet[l.CommonName] = true
|
||||
}
|
||||
var merged []model.ConnectionLog
|
||||
merged = append(merged, live...)
|
||||
for _, h := range history {
|
||||
if h.DisconnectedAt == nil && liveSet[h.CommonName] {
|
||||
continue // 已用实时数据替代
|
||||
}
|
||||
merged = append(merged, h)
|
||||
}
|
||||
// 同时返回诊断信息(方便排查),前端忽略 _diag 字段
|
||||
c.JSON(200, gin.H{
|
||||
"logs": merged,
|
||||
"_diag": diag,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- backups ----
|
||||
|
||||
@@ -41,6 +41,7 @@ type Instance struct {
|
||||
AccessMode AccessMode `json:"access_mode"` // open | whitelist
|
||||
AllowNetworks []string `json:"allow_networks"` // 实例级白名单 CIDR 列表
|
||||
AuthMode AuthMode `json:"auth_mode"` // cert | cert+password
|
||||
PublicHost string `json:"public_host"` // 公网域名/IP,生成 .ovpn 时优先用它;空则用下载时前端传入的 host
|
||||
Status string `json:"status"` // running/stopped/error
|
||||
PID int `json:"pid"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -36,6 +37,62 @@ func New(cfg *config.Config, st *store.Store, ovm *openvpn.Manager) *Service {
|
||||
return &Service{Cfg: cfg, Store: st, Ovm: ovm}
|
||||
}
|
||||
|
||||
// StartStatusSync 启动后台定时任务,每 10 秒读取所有实例的 status.log,
|
||||
// 解析 CLIENT_LIST 并同步到 ConnLogs。应在 main.go 中以 goroutine 启动。
|
||||
func (s *Service) StartStatusSync() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
s.syncAllInstanceStatus()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Service) syncAllInstanceStatus() {
|
||||
instances := s.Store.ListInstances()
|
||||
for _, in := range instances {
|
||||
// 不检查 Status 字段,直接尝试读 status.log。
|
||||
// OpenVPN 运行时会自动写 status.log,读到就同步。
|
||||
statusPath := filepath.Join(s.Cfg.InstanceDir(in.Name), "status.log")
|
||||
entries, err := s.Ovm.ParseStatus(statusPath)
|
||||
if err != nil {
|
||||
// 文件不存在或解析失败,跳过
|
||||
continue
|
||||
}
|
||||
log.Printf("[status-sync] instance=%s entries=%d", in.Name, len(entries))
|
||||
// 构建当前在线 CN 集合
|
||||
onlineCNs := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
onlineCNs[e.CommonName] = true
|
||||
// 如果没有活跃连接记录,则新建
|
||||
if s.Store.FindActiveConn(in.ID, e.CommonName) == nil {
|
||||
_ = s.Store.AppendConnLog(model.ConnectionLog{
|
||||
InstanceID: in.ID,
|
||||
CommonName: e.CommonName,
|
||||
RealIP: e.RealAddress,
|
||||
VPNIP: e.VPNAddress,
|
||||
BytesIn: e.BytesRecv,
|
||||
BytesOut: e.BytesSent,
|
||||
ConnectedAt: e.ConnectedAt,
|
||||
})
|
||||
log.Printf("[status-sync] new conn: %s %s %s", e.CommonName, e.RealAddress, e.VPNAddress)
|
||||
} else {
|
||||
// 更新流量统计
|
||||
_ = s.Store.UpdateActiveConnStats(in.ID, e.CommonName, e.BytesRecv, e.BytesSent)
|
||||
}
|
||||
}
|
||||
// 标记已断开的连接
|
||||
for _, c := range s.Store.ListActiveConns(in.ID) {
|
||||
if !onlineCNs[c.CommonName] {
|
||||
now := time.Now()
|
||||
_ = s.Store.CloseActiveConn(in.ID, c.CommonName, now)
|
||||
log.Printf("[status-sync] disconnected: %s", c.CommonName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) audit(c context.Context, action, target, detail, result, ip string) {
|
||||
username, _ := c.Value("user").(string)
|
||||
if username == "" {
|
||||
@@ -343,7 +400,8 @@ func (s *Service) CreateUser(u model.VPNUser) (*model.VPNUser, error) {
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GenerateOVPN 下载/重新生成 .ovpn,remoteHost 由前端传入。
|
||||
// GenerateOVPN 下载/重新生成 .ovpn。
|
||||
// remoteHost 优先级:Instance.PublicHost > 传入的 remoteHost > "vpn.example.com"。
|
||||
func (s *Service) GenerateOVPN(userID, remoteHost string) (string, error) {
|
||||
u, err := s.Store.GetUser(userID)
|
||||
if err != nil {
|
||||
@@ -353,7 +411,11 @@ func (s *Service) GenerateOVPN(userID, remoteHost string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.Ovm.GenerateClientOVPNFor(u, in, remoteHost)
|
||||
host := in.PublicHost
|
||||
if host == "" {
|
||||
host = remoteHost
|
||||
}
|
||||
return s.Ovm.GenerateClientOVPNFor(u, in, host)
|
||||
}
|
||||
|
||||
// RevokeUser 吊销用户:禁用 + 标记。
|
||||
|
||||
@@ -287,6 +287,35 @@ func (s *Store) CloseActiveConn(instanceID, commonName string, at time.Time) err
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListActiveConns 返回指定实例所有未断开的连接。
|
||||
func (s *Store) ListActiveConns(instanceID string) []model.ConnectionLog {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var out []model.ConnectionLog
|
||||
for i := len(s.data.ConnLogs) - 1; i >= 0; i-- {
|
||||
c := s.data.ConnLogs[i]
|
||||
if c.InstanceID == instanceID && c.DisconnectedAt == nil {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UpdateActiveConnStats 更新活跃连接的流量统计。
|
||||
func (s *Store) UpdateActiveConnStats(instanceID, commonName string, bytesIn, bytesOut int64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i := len(s.data.ConnLogs) - 1; i >= 0; i-- {
|
||||
c := &s.data.ConnLogs[i]
|
||||
if c.InstanceID == instanceID && c.CommonName == commonName && c.DisconnectedAt == nil {
|
||||
c.BytesIn = bytesIn
|
||||
c.BytesOut = bytesOut
|
||||
return nil // 不 flush,避免高频写盘
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- Backups ----
|
||||
|
||||
func (s *Store) ListBackups() []model.Backup {
|
||||
|
||||
+177
-13
@@ -2,6 +2,7 @@ package openvpn
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
@@ -614,36 +615,172 @@ type StatusEntry struct {
|
||||
|
||||
var (
|
||||
reHdr = regexp.MustCompile(`^Updated,([^,]+),`)
|
||||
reCli = regexp.MustCompile(`^CLIENT_LIST,([^,]+),([^,]+),([^,]+),(\d+),(\d+),`)
|
||||
// status-version 3 CLIENT_LIST 格式(11个字段):
|
||||
// CLIENT_LIST,CommonName,RealAddress,VirtualAddress,VirtualIPv6,BytesRecv,BytesSent,ConnectedSince,Username,ClientID,PeerID
|
||||
reCli3 = regexp.MustCompile(`^CLIENT_LIST,([^,]+),([^,]+),([^,]+),([^,]*),(\d+),(\d+),([^,]+),`)
|
||||
// status-version 1 CLIENT 数据行(5 列):
|
||||
// Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
|
||||
// test,1.2.3.4:1234,1024,2048,"Sat Aug 9 16:14:10 2025"
|
||||
reCli1 = regexp.MustCompile(`^([^,]+),([^,]+),(\d+),(\d+),"?([^",]+)"?,?`)
|
||||
reTime = regexp.MustCompile(`^Connected Since,([^,]+),`)
|
||||
)
|
||||
|
||||
// ParseStatusReader 解析 OpenVPN status 文件。
|
||||
// 同时支持 status-version 1(人类可读列名)和 status-version 3(CSV/TAB)。
|
||||
//
|
||||
// v1 格式(默认,OpenVPN 未指定 status-version 时使用):
|
||||
// Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
|
||||
// test,1.2.3.4:1234,1024,2048,Sat Aug 9 16:14:10 2025
|
||||
//
|
||||
// v3 格式(status-version 3,11-13 字段):
|
||||
// CLIENT_LIST,CN,RealAddr,VPNAddr,IPv6,BytesRecv,BytesSent,ConnectedSince[,Username,ClientID,PeerID[,Cipher]]
|
||||
// 注意不同 OpenVPN 版本 IPv6 字段可能存在/缺失/为空,不能依赖固定位置,改为按字段名匹配。
|
||||
func ParseStatusReader(r io.Reader) ([]StatusEntry, error) {
|
||||
sc := bufio.NewScanner(r)
|
||||
// 先全读入内存,方便做两次扫描(v1 需要从 ROUTING_TABLE 取 VPN IP)
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc := bufio.NewScanner(bytes.NewReader(b))
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
var out []StatusEntry
|
||||
mode := "" // "" / "v1" / "v3"
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
switch {
|
||||
case strings.HasPrefix(line, "CLIENT_LIST,"):
|
||||
m := reCli.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
if mode == "" {
|
||||
if strings.HasPrefix(line, "TITLE,") || strings.HasPrefix(line, "TITLE ") {
|
||||
mode = "v3"
|
||||
} else if strings.HasPrefix(line, "OpenVPN CLIENT LIST") {
|
||||
mode = "v1"
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch mode {
|
||||
case "v3":
|
||||
// 解析 HEADER 行得到列名(以便按位置名取值)。但因为 OpenVPN 2.5 的 HEADER
|
||||
// 没有"Virtual IPv6 Address"和"Cipher"两个空字段(或反之),位置经常错位。
|
||||
// 退而求其次:不用 HEADER,直接固定按 parts[1..] 取,加上 IPv6 字段探测:
|
||||
// - 标准 11 列(无 IPv6、无 Cipher):CN,RealAddr,VPNAddr,BytesRecv,
|
||||
// BytesSent,Time,TimeT,User,ClientID,PeerID
|
||||
// - 12 列(无 IPv6、有 Cipher):...,Cipher
|
||||
// - 13 列(都有):CN,RealAddr,VPNAddr,IPv6(empty),?,BytesRecv,
|
||||
// BytesSent,Time,TimeT,User,ClientID,PeerID,Cipher
|
||||
if !strings.HasPrefix(line, "CLIENT_LIST") {
|
||||
continue
|
||||
}
|
||||
sep := " "
|
||||
if !strings.Contains(line, " ") {
|
||||
sep = ","
|
||||
}
|
||||
parts := strings.Split(line, sep)
|
||||
if len(parts) < 7 {
|
||||
continue
|
||||
}
|
||||
// 检测是否有 "Virtual IPv6 Address" 字段:扫描整行,
|
||||
// CLIENT_LIST 后面第 4 个 tab 段如果是空字符串 -> 有 IPv6 字段
|
||||
data := parts[1:]
|
||||
// 找第二个非空 tab 段:如果 [3] 是空且 [4] 也是空 -> IPv6 在 [3];否则 IPv6 不存在
|
||||
// 用更简单的策略:按数据值猜测
|
||||
// [1]=RealAddr 必含 ":" 或纯数字IP; [2]=VPNAddr 必为 IP(可能是空 IPv4,IPv4 全数字)
|
||||
// bytes 字段是数字。先找到第一个纯数字段作为 BytesRecv
|
||||
e := StatusEntry{CommonName: strings.TrimSpace(data[0])}
|
||||
if len(data) > 1 {
|
||||
e.RealAddress = strings.TrimSpace(data[1])
|
||||
}
|
||||
// 寻找第一个看起来像数字的字段位置 = Bytes Received
|
||||
// 排除 RealAddress(包含 ":")、VPNAddress(纯 IP)、ConnectedSince(包含 "-")
|
||||
bytesIdx := -1
|
||||
for i := 2; i < len(data); i++ {
|
||||
v := strings.TrimSpace(data[i])
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
// 纯数字(可能很长,如 1786292298)
|
||||
if _, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
bytesIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if bytesIdx == -1 || bytesIdx+1 >= len(data) {
|
||||
continue
|
||||
}
|
||||
e.BytesRecv = atoi64(data[bytesIdx])
|
||||
e.BytesSent = atoi64(data[bytesIdx+1])
|
||||
// VPNAddress:如果有 IPv6 字段,它在 [3];否则在 [3] 是直接 IP
|
||||
// 这里已用 CN+RealAddr 标识,VPNAddress 是次要的,从 [2] 取或留空
|
||||
if len(data) > 2 {
|
||||
e.VPNAddress = strings.TrimSpace(data[2])
|
||||
}
|
||||
// ConnectedSince:在 BytesRecv+2 位置(后面跟着 time_t)
|
||||
if bytesIdx+2 < len(data) {
|
||||
e.ConnectedAt = parseConnTime(strings.TrimSpace(data[bytesIdx+2]))
|
||||
}
|
||||
if e.CommonName != "" {
|
||||
out = append(out, e)
|
||||
}
|
||||
case "v1":
|
||||
// 跳过表头和节标记
|
||||
if strings.HasPrefix(line, "Common Name,") || strings.HasPrefix(line, "HEADER,") ||
|
||||
strings.HasPrefix(line, "ROUTING TABLE") || strings.HasPrefix(line, "Virtual Address,") ||
|
||||
strings.HasPrefix(line, "GLOBAL STATS") || strings.HasPrefix(line, "END") ||
|
||||
strings.HasPrefix(line, "Updated,") {
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(line, ",")
|
||||
if len(parts) < 5 {
|
||||
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,
|
||||
CommonName: strings.TrimSpace(parts[0]),
|
||||
RealAddress: strings.TrimSpace(parts[1]),
|
||||
VPNAddress: "",
|
||||
BytesRecv: atoi64(parts[2]),
|
||||
BytesSent: atoi64(parts[3]),
|
||||
ConnectedAt: parseConnTime(strings.TrimSpace(parts[4])),
|
||||
})
|
||||
}
|
||||
}
|
||||
// v1 模式:用 ROUTING_TABLE 补充 VPNAddress
|
||||
if mode == "v1" {
|
||||
v1VPN := parseV1RoutingTable(bytes.NewReader(b))
|
||||
for i := range out {
|
||||
if ip, ok := v1VPN[out[i].CommonName]; ok {
|
||||
out[i].VPNAddress = ip
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, sc.Err()
|
||||
}
|
||||
|
||||
// parseV1RoutingTable 从 v1 status 中解析 ROUTING_TABLE 部分,返回 CN→VPNIP。
|
||||
func parseV1RoutingTable(r io.Reader) map[string]string {
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
out := map[string]string{}
|
||||
inRouting := false
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if strings.HasPrefix(line, "ROUTING TABLE") {
|
||||
inRouting = true
|
||||
continue
|
||||
}
|
||||
if !inRouting {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "GLOBAL STATS") || strings.HasPrefix(line, "END") {
|
||||
break
|
||||
}
|
||||
parts := strings.Split(line, ",")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
out[parts[1]] = parts[0]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsRunning 通过 TCP 探测 openvpn 端口是否可连,仅供参考。
|
||||
func (m *Manager) IsRunning(host string, port int) bool {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
@@ -693,6 +830,33 @@ func atoi64(s string) int64 {
|
||||
return n
|
||||
}
|
||||
|
||||
// parseConnTime 解析 OpenVPN status.log 中的时间字段。
|
||||
// status-version 3 格式: "Mon Jan 2 15:04:05 2006" (注意可能有双空格)
|
||||
// 部分版本使用 Unix 时间戳。
|
||||
func parseConnTime(s string) time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
// 先尝试 Unix 时间戳
|
||||
if ts, err := strconv.ParseInt(s, 10, 64); err == nil && ts > 1000000000 {
|
||||
return time.Unix(ts, 0)
|
||||
}
|
||||
// 标准格式
|
||||
formats := []string{
|
||||
"Mon Jan 2 15:04:05 2006",
|
||||
"Mon Jan 2 15:04:05 2006",
|
||||
"2006-01-02 15:04:05",
|
||||
time.RFC3339,
|
||||
}
|
||||
for _, f := range formats {
|
||||
if t, err := time.Parse(f, s); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// cidrToServerDirective 把 "10.8.0.0/24" 转成 OpenVPN server 指令需要的
|
||||
// "10.8.0.0 255.255.255.0" 格式(网络地址 + 点分掩码)。
|
||||
// 如果输入不含 "/"(已经是 "ip mask" 形式),原样返回。
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
<el-table-column prop="proto" label="协议" width="80" />
|
||||
<el-table-column prop="dev" label="设备" width="80" />
|
||||
<el-table-column prop="subnet" label="子网" />
|
||||
<el-table-column prop="public_host" label="公网地址">
|
||||
<template #default="{row}">
|
||||
<span v-if="row.public_host" style="font-family:monospace">{{ row.public_host }}</span>
|
||||
<span v-else class="muted">未配置</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="访问控制" width="110">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.access_mode==='whitelist'?'warning':'info'" size="small">
|
||||
@@ -53,6 +59,12 @@
|
||||
<el-select v-model="form.dev"><el-option label="tun" value="tun"/><el-option label="tap" value="tap"/></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="子网"><el-input v-model="form.subnet" placeholder="10.8.0.0/24"/></el-form-item>
|
||||
<el-form-item label="公网地址">
|
||||
<el-input v-model="form.public_host" placeholder="vpn.example.com 或 203.0.113.10" />
|
||||
<div class="muted" style="margin-top:4px">
|
||||
客户端连接时用的服务端地址。<b>留空</b>则下载 .ovpn 时手动输入;<b>填写</b>则所有用户下载的 .ovpn 自动用此地址,无需修改。
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="加密">
|
||||
<el-select v-model="form.cipher"><el-option label="AES-256-GCM" value="AES-256-GCM"/><el-option label="AES-128-GCM" value="AES-128-GCM"/><el-option label="CHACHA20-POLY1305" value="CHACHA20-POLY1305"/></el-select>
|
||||
</el-form-item>
|
||||
@@ -103,7 +115,7 @@ const router = useRouter()
|
||||
|
||||
async function load() { list.value = await Inst.list() }
|
||||
function openCreate() {
|
||||
Object.assign(form, { name:'', port:1194, proto:'udp', dev:'tun', subnet:'10.8.0.0/24', cipher:'AES-256-GCM', auth_digest:'SHA256', push_dns:'', push_routes:'', extra:'', access_mode:'open', allow_networks:[], id:'' })
|
||||
Object.assign(form, { name:'', port:1194, proto:'udp', dev:'tun', subnet:'10.8.0.0/24', cipher:'AES-256-GCM', auth_digest:'SHA256', push_dns:'', push_routes:'', extra:'', access_mode:'open', allow_networks:[], public_host:'', id:'' })
|
||||
dlg.value = true
|
||||
}
|
||||
function openEdit(row) {
|
||||
|
||||
@@ -33,7 +33,11 @@ const list = ref([])
|
||||
const instances = ref([])
|
||||
const instanceId = ref('')
|
||||
|
||||
async function load() { list.value = await Logs.conns(instanceId.value) }
|
||||
async function load() {
|
||||
const r = await Logs.conns(instanceId.value)
|
||||
// 后端可能返回数组(旧)或 {logs:[...], _diag:[...]}(新)
|
||||
list.value = Array.isArray(r) ? r : (r.logs || [])
|
||||
}
|
||||
function fmt(n) {
|
||||
if (!n) return '0'
|
||||
const u = ['B','KB','MB','GB','TB']; let i=0,v=n
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<el-option v-for="i in instances" :key="i.id" :value="i.id" :label="`${i.name} (${i.proto}/${i.port})`" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="6"><el-input v-model="host" placeholder="客户端连接的远端域名/IP" /></el-col>
|
||||
<el-col :span="6"><el-input v-model="host" placeholder="可选: 覆盖实例公网地址" /></el-col>
|
||||
<el-col :span="4"><el-button @click="loadUsers">刷新</el-button></el-col>
|
||||
<el-col :span="6" style="text-align:right">
|
||||
<el-button type="primary" :disabled="!instanceId" @click="openCreate"><el-icon><Plus /></el-icon>新建用户</el-button>
|
||||
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
# 连接日志一键诊断脚本
|
||||
# 在生产服务器上以 root 执行: bash /opt/openvpn-manager/scripts/diag-connlogs.sh
|
||||
set -u
|
||||
echo "=========================================="
|
||||
echo " OpenVPN Manager 连接日志诊断"
|
||||
echo "=========================================="
|
||||
|
||||
echo ""
|
||||
echo "[1] 服务运行状态:"
|
||||
ps -ef | grep -E "openvpn-manager|openvpn " | grep -v grep || echo " (无进程)"
|
||||
|
||||
echo ""
|
||||
echo "[2] 当前代码版本:"
|
||||
cd /opt/openvpn-manager 2>/dev/null && git log --oneline -3 || echo " git log 失败"
|
||||
|
||||
echo ""
|
||||
echo "[3] 二进制构建时间:"
|
||||
ls -la /opt/openvpn-manager/bin/openvpn-manager 2>/dev/null | awk '{print " "$6,$7,$8,$9}'
|
||||
|
||||
echo ""
|
||||
echo "[4] status.log 文件:"
|
||||
find /opt/openvpn-manager/data/instances -name "status.log" 2>/dev/null | while read f; do
|
||||
echo " 路径: $f"
|
||||
echo " 大小: $(stat -c%s "$f") 字节"
|
||||
echo " 内容前 6 行:"
|
||||
head -6 "$f" | sed 's/^/ /'
|
||||
echo " ----"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "[5] 直接登录 API:"
|
||||
LOGIN_RESP=$(curl -s http://localhost:8089/api/login -X POST -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}')
|
||||
echo " 响应: $LOGIN_RESP"
|
||||
TOKEN=$(echo "$LOGIN_RESP" | grep -oP '"token":"\K[^"]+')
|
||||
echo " TOKEN 长度: ${#TOKEN}"
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo " ✗ 登录失败,无法继续"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[6] 调用 /api/connlogs:"
|
||||
RESP=$(curl -s "http://localhost:8089/api/connlogs" -H "Authorization: Bearer $TOKEN")
|
||||
echo " 响应: $RESP"
|
||||
|
||||
echo ""
|
||||
echo "[7] 解析日志条目数:"
|
||||
echo "$RESP" | grep -oP '"common_name":"[^"]+"' | wc -l | awk '{print " 共 "$1" 条连接记录"}'
|
||||
|
||||
echo ""
|
||||
echo "[8] 关键检查:"
|
||||
echo "$RESP" | grep -q '"_diag"' && echo " ✓ 返回了新格式(带 _diag)" || echo " ✗ 旧格式,说明后端代码未更新"
|
||||
echo "$RESP" | grep -q '"logs"' && echo " ✓ logs 字段存在" || echo " ✗ 缺 logs 字段"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " 诊断完成"
|
||||
echo "=========================================="
|
||||
@@ -172,6 +172,25 @@ for i in 1 2 3 4 5; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ---------- 启用 IP 转发 (客户端访问服务端 LAN 的前提) ----------
|
||||
echo "==> 启用 IPv4 转发 (客户端访问服务端 LAN 需要)"
|
||||
if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then
|
||||
echo 1 > /proc/sys/net/ipv4/ip_forward
|
||||
# 持久化(下次重启不丢)
|
||||
CONF_FILE="/etc/sysctl.d/99-openvpn-manager.conf"
|
||||
if [[ ! -f "$CONF_FILE" ]] || ! grep -q "^net.ipv4.ip_forward=1" "$CONF_FILE" 2>/dev/null; then
|
||||
echo "net.ipv4.ip_forward=1" > "$CONF_FILE"
|
||||
sysctl -p "$CONF_FILE" >/dev/null 2>&1 || true
|
||||
fi
|
||||
echo " ✓ IP 转发已启用 (持久化到 $CONF_FILE)"
|
||||
echo " 提示: 如需让客户端访问服务端 LAN 网段, 还需手动添加 iptables NAT 规则:"
|
||||
echo " sudo iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o <LAN网卡> -j MASQUERADE"
|
||||
echo " 详见 README \"IP 转发与内网访问\" 章节"
|
||||
else
|
||||
echo " ! 无法写入 /proc/sys/net/ipv4/ip_forward (容器环境?) — 跳过"
|
||||
echo " 如需客户端访问服务端 LAN, 请在宿主机启用: sysctl -w net.ipv4.ip_forward=1"
|
||||
fi
|
||||
|
||||
# ---------- 完成提示 ----------
|
||||
cat <<EOF
|
||||
|
||||
|
||||
Reference in New Issue
Block a user