8 Commits

Author SHA1 Message Date
cnbugs a5519c2f6c Feature: per-instance public_host for .ovpn
When creating/editing an instance, add '公网地址' (public_host) field.
This is the domain/IP clients will use to reach the OpenVPN server.
When downloading a .ovpn, this field is used first, falling back to:
  1. ?host= query parameter (one-time override)
  2. Request Host header
  3. 'vpn.example.com' default

This eliminates the need to manually edit .ovpn files after download
when the server's public address differs from its internal IP.

Backend changes:
  model.Instance: add PublicHost string field
  service.GenerateOVPN: priority PublicHost > remoteHost > default
  service.UpdateInstance: PublicHost persisted (already via JSON tag)

Frontend changes:
  Instances.vue: new '公网地址' input in create/edit dialog
  Instances.vue: new column in list table (shows '未配置' when empty)
  Users.vue: hint text updated to '可选: 覆盖实例公网地址'

E2E verified:
  public_host='vpn.yunwei.blog'  → .ovpn has 'remote vpn.yunwei.blog 1194'
  public_host='' + host=X        → .ovpn has 'remote X 1194'
  public_host set + no host      → public_host wins
2026-08-10 00:35:30 +08:00
cnbugs 2b0f144abc Docs: add IP forwarding/NAT section for client-to-LAN access
The most common OpenVPN deployment scenario is letting remote users
access the server's LAN (office/home network). This requires:
1. net.ipv4.ip_forward=1
2. iptables MASQUERADE on the LAN-facing interface
3. push 'route <LAN-net>' so clients know to send LAN traffic through VPN

Previously this was only mentioned in the FAQ row 'ping不通服务端',
with no actionable instructions. Users (including the project's own
first deployment) hit this exact issue and had to figure it out from
forum posts.

Changes:
  README.md:
  - New section 'IP 转发与内网访问' before 防火墙与公网暴露
  - Covers: enabling ip_forward (immediate + persistent via sysctl.d),
    MASQUERADE rules for one/multiple LAN interfaces, push routes,
    verification commands, troubleshooting table, full checklist
  - FAQ table: add explicit row for 'gateway reachable, LAN not'
  - Features table: add '内网转发' row
  - TOC: link new section

  scripts/install.sh:
  - Auto-enable ip_forward on install (idempotent, persistent via
    /etc/sysctl.d/99-openvpn-manager.conf)
  - Skip silently in containerized environments (no /proc/sys write)
  - Print reminder about manual MASQUERADE rule with link to docs
2026-08-10 00:26:34 +08:00
cnbugs 5725149546 Fix v3 parser: scan for numeric fields (IPv6 field position varies)
The user's OpenVPN 2.5.11 outputs CLIENT_LIST with 13 tab-separated
fields:
  CLIENT_LIST CN RealAddr VPNAddr IPv6(empty) (empty) BytesRecv
              BytesSent ConnectedSince (time_t) Username ClientID
              PeerID Cipher

But OpenVPN 2.6+ drops the IPv6 and Cipher fields (11 columns), and
some versions include 'Connected Since (time_t)' which moves all
positions.

Solution: parse by content type, not position:
  - parts[0] = 'CLIENT_LIST'
  - parts[1] = CN (string, may be empty)
  - parts[2] = RealAddr (string with ':')
  - parts[3] = VPNAddr (IP, may be empty)
  - parts[4..] = scan for first pure-numeric field → BytesRecv
  - BytesRecv+1 = BytesSent
  - BytesRecv+2 = ConnectedSince

Verified against the user's real status.log:
  CN=test RealAddr=123.118.73.196:17564 VPNAddr=10.8.0.2
  bytesIn=629025 bytesOut=539217
2026-08-10 00:22:02 +08:00
cnbugs 9054da954a Add diag-connlogs.sh diagnostic script 2026-08-10 00:19:20 +08:00
cnbugs 9f24c74527 Fix status parser: support status-version 1 (default OpenVPN format)
Root cause: user's status.log uses default OpenVPN format (status-version 1)
with human-readable column names. Our parser only handled status-version 3
(CSV with 11 fields per CLIENT_LIST line).

v1 format observed on production:
  Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
  test,123.118.73.196:18257,423700,455528,2026-08-09 16:14:10

Changes:
  pkg/openvpn/manager.go:
  - Detect format by scanning first line: TITLE, → v3, OpenVPN CLIENT LIST → v1
  - Add reCli1 regex for v1 5-column data rows
  - v1 mode: read full file, parse CLIENT_LIST then parse ROUTING_TABLE
    separately to associate CN→VPNAddress (v1 doesn't include VPN IP
    in the client data row)
  - Add bytes import

  Both formats now work side-by-side.
2026-08-10 00:16:28 +08:00
cnbugs 1706552b33 ConnLogs: return object with _diag for troubleshooting + frontend compat
API now returns:
{
  "logs": [...],    // ConnectionLog array (same fields as before)
  "_diag": [...]    // status.log paths/existence/parse status per instance
}

Frontend updated to handle both array (legacy) and object format.

This makes it easy to diagnose:
- Which status.log paths the backend looked at
- Whether files exist and their sizes
- How many entries were parsed
- Any parse errors

User just needs to: git pull && rebuild && restart on production.
2026-08-10 00:12:00 +08:00
cnbugs 81544d2335 Fix connection logs: real-time status.log parsing
Root cause: listConnLogs only read from db.json which was never
populated (background sync silently failed due to Status check and
broken regex).

Fix approach: read status.log directly in API handlers — no DB
dependency, works immediately when OpenVPN is running.

Changes:
  api/router.go:
  - listConnLogs: parse status.log from all instances in real-time,
    merge with historical DB entries for disconnected sessions
  - dashboard: same real-time approach for recent_conn_logs

  pkg/openvpn/manager.go:
  - Fix regex for status-version 3 format (11 fields):
    CLIENT_LIST,CN,RealAddr,VPNAddr,IPv6,BytesRecv,BytesSent,ConnectedSince,...
    Old regex assumed wrong field order (CN,ConnectedSince,RealAddr)
  - Add parseConnTime() helper supporting multiple time formats

  internal/service/service.go:
  - Remove Status=='running' check (was blocking sync for all instances)
  - Add log.Printf debug output for sync operations
  - Add 'log' import

  internal/store/store.go:
  - Add ListActiveConns(instanceID) for background sync
  - Add UpdateActiveConnStats() for traffic updates without flush
2026-08-10 00:06:40 +08:00
cnbugs 19c0b190ea Fix connection logs: add background status.log sync task
ConnLogs were never populated because AppendConnLog was never called.
Added StartStatusSync() goroutine that runs every 10 seconds:

1. Iterates all running instances
2. Reads status.log via ParseStatus (status-version 3)
3. For each CLIENT_LIST entry:
   - If no active ConnLog exists → AppendConnLog (new connection)
   - If active ConnLog exists → UpdateActiveConnStats (traffic)
4. For active ConnLogs with no matching CLIENT_LIST → CloseActiveConn

Store additions:
  ListActiveConns(instanceID)  — returns all unclosed connections
  UpdateActiveConnStats()      — updates bytes in/out without flush

StartStatusSync is called from main.go right after Service creation.
UpdateActiveConnStats deliberately skips flush() to avoid writing
db.json every 10 seconds; stats are persisted on disconnect.
2026-08-09 23:52:17 +08:00
12 changed files with 586 additions and 25 deletions
+126 -2
View File
@@ -23,8 +23,9 @@
- [二、首次配置](#二首次配置)
- [登录 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) |
+1
View File
@@ -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)
+88 -3
View File
@@ -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 ----
+1
View File
@@ -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"`
+64 -2
View File
@@ -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 下载/重新生成 .ovpnremoteHost 由前端传入
// 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 吊销用户:禁用 + 标记。
+29
View File
@@ -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
View File
@@ -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 3CSV/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 311-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" 形式),原样返回。
+13 -1
View File
@@ -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) {
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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>
+60
View File
@@ -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 "=========================================="
+19
View File
@@ -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