Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5519c2f6c | |||
| 2b0f144abc | |||
| 5725149546 | |||
| 9054da954a | |||
| 9f24c74527 | |||
| 1706552b33 | |||
| 81544d2335 | |||
| 19c0b190ea |
@@ -23,8 +23,9 @@
|
|||||||
- [二、首次配置](#二首次配置)
|
- [二、首次配置](#二首次配置)
|
||||||
- [登录 Web 控制台](#登录-web-控制台)
|
- [登录 Web 控制台](#登录-web-控制台)
|
||||||
- [修改默认密码](#修改默认密码)
|
- [修改默认密码](#修改默认密码)
|
||||||
- [创建第一个实例](#创建第一个实例)
|
| - [创建第一个实例](#创建第一个实例)
|
||||||
- [创建客户端用户并下载配置](#创建客户端用户并下载配置)
|
- [创建客户端用户并下载配置](#创建客户端用户并下载配置)
|
||||||
|
- [IP 转发与内网访问](#ip-转发与内网访问客户端访问服务端-lan)
|
||||||
- [防火墙与公网暴露](#防火墙与公网暴露)
|
- [防火墙与公网暴露](#防火墙与公网暴露)
|
||||||
- [三、访问控制(白名单模式)](#三访问控制白名单模式)
|
- [三、访问控制(白名单模式)](#三访问控制白名单模式)
|
||||||
- [四、客户端使用](#四客户端使用)
|
- [四、客户端使用](#四客户端使用)
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
| 一键恢复 | 上传/选择已有备份,直接覆盖还原 |
|
| 一键恢复 | 上传/选择已有备份,直接覆盖还原 |
|
||||||
| 操作审计 | 所有写操作(创建/修改/删除/吊销/备份)持久化,记录操作者/IP/结果 |
|
| 操作审计 | 所有写操作(创建/修改/删除/吊销/备份)持久化,记录操作者/IP/结果 |
|
||||||
| JWT 鉴权 | 登录后 12 小时有效 token,无状态可水平扩展 |
|
| JWT 鉴权 | 登录后 12 小时有效 token,无状态可水平扩展 |
|
||||||
|
| **内网转发** | 文档化 IP 转发 + NAT 一键配置,客户端可访问服务端 LAN |
|
||||||
|
|
||||||
## 架构概览
|
## 架构概览
|
||||||
|
|
||||||
@@ -369,6 +371,127 @@ Web → "用户管理":
|
|||||||
|
|
||||||
直接发给用户即可,无需额外的 `ca.crt` 等文件。
|
直接发给用户即可,无需额外的 `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)**不建议直接暴露公网**,建议:
|
1. Web 管理端口(默认 8089)**不建议直接暴露公网**,建议:
|
||||||
@@ -647,8 +770,9 @@ sudo dnf install -y NetworkManager-openvpn NetworkManager-openvpn-gnome # F
|
|||||||
| 症状 | 可能原因 |
|
| 症状 | 可能原因 |
|
||||||
| ---- | -------- |
|
| ---- | -------- |
|
||||||
| 连接后立刻断开 | 客户端证书与 CA 不匹配;服务端证书过期 |
|
| 连接后立刻断开 | 客户端证书与 CA 不匹配;服务端证书过期 |
|
||||||
| 拿到 IP 但 ping 不通服务端 | 防火墙没允许 UDP 1194;服务端没启用 IP 转发 |
|
| 拿到 IP 但 ping 不通服务端 | 防火墙没允许 UDP 1194;服务端没启用 IP 转发(见 [IP 转发与内网访问](#ip-转发与内网访问客户端访问服务端-lan)) |
|
||||||
| 拿到 IP 但访问不了互联网 | 没推送 DNS,或客户端没把 VPN 设为默认网关 |
|
| 拿到 IP 但访问不了互联网 | 没推送 DNS,或客户端没把 VPN 设为默认网关 |
|
||||||
|
| 拿到 IP 能 ping 通网关,访问不了 LAN | **没做服务端 NAT** — 见 [IP 转发与内网访问](#ip-转发与内网访问客户端访问服务端-lan) |
|
||||||
| Android 连不上 | 服务器在 NAT 后,检查运营商是否屏蔽 UDP |
|
| Android 连不上 | 服务器在 NAT 后,检查运营商是否屏蔽 UDP |
|
||||||
| iOS 连不上 | 看 OpenVPN 日志(应用内 OpenVPN → Settings → Log) |
|
| iOS 连不上 | 看 OpenVPN 日志(应用内 OpenVPN → Settings → Log) |
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ func main() {
|
|||||||
log.Printf("warn: ensure CA: %v", err)
|
log.Printf("warn: ensure CA: %v", err)
|
||||||
}
|
}
|
||||||
svc := service.New(cfg, st, ovm)
|
svc := service.New(cfg, st, ovm)
|
||||||
|
svc.StartStatusSync() // 后台定时同步连接日志
|
||||||
|
|
||||||
addr := cfg.Host + ":" + itoa(cfg.Port)
|
addr := cfg.Host + ":" + itoa(cfg.Port)
|
||||||
log.Printf("openvpn-manager listening on %s, data=%s, dist=%s", addr, cfg.DataDir, *distDir)
|
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,
|
"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{
|
c.JSON(200, gin.H{
|
||||||
"instances": len(instances),
|
"instances": len(instances),
|
||||||
"running": countByStatus(instances, "running"),
|
"running": countByStatus(instances, "running"),
|
||||||
@@ -226,7 +253,7 @@ func (s *Server) dashboard(c *gin.Context) {
|
|||||||
"online": online,
|
"online": online,
|
||||||
"expiring_certs": expiring,
|
"expiring_certs": expiring,
|
||||||
"recent_audits": s.Svc.Store.ListAudits(20),
|
"recent_audits": s.Svc.Store.ListAudits(20),
|
||||||
"recent_conn_logs": s.Svc.Store.ListConnLogs("", 20),
|
"recent_conn_logs": recentConnLogs,
|
||||||
"access_overview": overview,
|
"access_overview": overview,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -465,9 +492,67 @@ func (s *Server) listCerts(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- conn logs ----
|
// ---- conn logs ----
|
||||||
|
|
||||||
func (s *Server) listConnLogs(c *gin.Context) {
|
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 ----
|
// ---- backups ----
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type Instance struct {
|
|||||||
AccessMode AccessMode `json:"access_mode"` // open | whitelist
|
AccessMode AccessMode `json:"access_mode"` // open | whitelist
|
||||||
AllowNetworks []string `json:"allow_networks"` // 实例级白名单 CIDR 列表
|
AllowNetworks []string `json:"allow_networks"` // 实例级白名单 CIDR 列表
|
||||||
AuthMode AuthMode `json:"auth_mode"` // cert | cert+password
|
AuthMode AuthMode `json:"auth_mode"` // cert | cert+password
|
||||||
|
PublicHost string `json:"public_host"` // 公网域名/IP,生成 .ovpn 时优先用它;空则用下载时前端传入的 host
|
||||||
Status string `json:"status"` // running/stopped/error
|
Status string `json:"status"` // running/stopped/error
|
||||||
PID int `json:"pid"`
|
PID int `json:"pid"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"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}
|
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) {
|
func (s *Service) audit(c context.Context, action, target, detail, result, ip string) {
|
||||||
username, _ := c.Value("user").(string)
|
username, _ := c.Value("user").(string)
|
||||||
if username == "" {
|
if username == "" {
|
||||||
@@ -343,7 +400,8 @@ func (s *Service) CreateUser(u model.VPNUser) (*model.VPNUser, error) {
|
|||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateOVPN 下载/重新生成 .ovpn,remoteHost 由前端传入。
|
// GenerateOVPN 下载/重新生成 .ovpn。
|
||||||
|
// remoteHost 优先级:Instance.PublicHost > 传入的 remoteHost > "vpn.example.com"。
|
||||||
func (s *Service) GenerateOVPN(userID, remoteHost string) (string, error) {
|
func (s *Service) GenerateOVPN(userID, remoteHost string) (string, error) {
|
||||||
u, err := s.Store.GetUser(userID)
|
u, err := s.Store.GetUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -353,7 +411,11 @@ func (s *Service) GenerateOVPN(userID, remoteHost string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return s.Ovm.GenerateClientOVPNFor(u, in, remoteHost)
|
host := in.PublicHost
|
||||||
|
if host == "" {
|
||||||
|
host = remoteHost
|
||||||
|
}
|
||||||
|
return s.Ovm.GenerateClientOVPNFor(u, in, host)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeUser 吊销用户:禁用 + 标记。
|
// RevokeUser 吊销用户:禁用 + 标记。
|
||||||
|
|||||||
@@ -287,6 +287,35 @@ func (s *Store) CloseActiveConn(instanceID, commonName string, at time.Time) err
|
|||||||
return nil
|
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 ----
|
// ---- Backups ----
|
||||||
|
|
||||||
func (s *Store) ListBackups() []model.Backup {
|
func (s *Store) ListBackups() []model.Backup {
|
||||||
|
|||||||
+177
-13
@@ -2,6 +2,7 @@ package openvpn
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -614,36 +615,172 @@ type StatusEntry struct {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
reHdr = regexp.MustCompile(`^Updated,([^,]+),`)
|
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,([^,]+),`)
|
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) {
|
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)
|
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
var out []StatusEntry
|
var out []StatusEntry
|
||||||
|
mode := "" // "" / "v1" / "v3"
|
||||||
for sc.Scan() {
|
for sc.Scan() {
|
||||||
line := sc.Text()
|
line := sc.Text()
|
||||||
switch {
|
if mode == "" {
|
||||||
case strings.HasPrefix(line, "CLIENT_LIST,"):
|
if strings.HasPrefix(line, "TITLE,") || strings.HasPrefix(line, "TITLE ") {
|
||||||
m := reCli.FindStringSubmatch(line)
|
mode = "v3"
|
||||||
if m == nil {
|
} 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
|
continue
|
||||||
}
|
}
|
||||||
connected, _ := time.Parse("Mon Jan 2 15:04:05 2006", m[2])
|
|
||||||
out = append(out, StatusEntry{
|
out = append(out, StatusEntry{
|
||||||
CommonName: m[1],
|
CommonName: strings.TrimSpace(parts[0]),
|
||||||
RealAddress: m[3],
|
RealAddress: strings.TrimSpace(parts[1]),
|
||||||
VPNAddress: m[4],
|
VPNAddress: "",
|
||||||
BytesRecv: atoi64(m[5]),
|
BytesRecv: atoi64(parts[2]),
|
||||||
BytesSent: atoi64(m[6]),
|
BytesSent: atoi64(parts[3]),
|
||||||
ConnectedAt: connected,
|
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()
|
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 端口是否可连,仅供参考。
|
// IsRunning 通过 TCP 探测 openvpn 端口是否可连,仅供参考。
|
||||||
func (m *Manager) IsRunning(host string, port int) bool {
|
func (m *Manager) IsRunning(host string, port int) bool {
|
||||||
addr := fmt.Sprintf("%s:%d", host, port)
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
@@ -693,6 +830,33 @@ func atoi64(s string) int64 {
|
|||||||
return n
|
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 指令需要的
|
// cidrToServerDirective 把 "10.8.0.0/24" 转成 OpenVPN server 指令需要的
|
||||||
// "10.8.0.0 255.255.255.0" 格式(网络地址 + 点分掩码)。
|
// "10.8.0.0 255.255.255.0" 格式(网络地址 + 点分掩码)。
|
||||||
// 如果输入不含 "/"(已经是 "ip mask" 形式),原样返回。
|
// 如果输入不含 "/"(已经是 "ip mask" 形式),原样返回。
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
<el-table-column prop="proto" label="协议" width="80" />
|
<el-table-column prop="proto" label="协议" width="80" />
|
||||||
<el-table-column prop="dev" label="设备" width="80" />
|
<el-table-column prop="dev" label="设备" width="80" />
|
||||||
<el-table-column prop="subnet" label="子网" />
|
<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">
|
<el-table-column label="访问控制" width="110">
|
||||||
<template #default="{row}">
|
<template #default="{row}">
|
||||||
<el-tag :type="row.access_mode==='whitelist'?'warning':'info'" size="small">
|
<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-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>
|
||||||
<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.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-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-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>
|
</el-form-item>
|
||||||
@@ -103,7 +115,7 @@ const router = useRouter()
|
|||||||
|
|
||||||
async function load() { list.value = await Inst.list() }
|
async function load() { list.value = await Inst.list() }
|
||||||
function openCreate() {
|
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
|
dlg.value = true
|
||||||
}
|
}
|
||||||
function openEdit(row) {
|
function openEdit(row) {
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ const list = ref([])
|
|||||||
const instances = ref([])
|
const instances = ref([])
|
||||||
const instanceId = 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) {
|
function fmt(n) {
|
||||||
if (!n) return '0'
|
if (!n) return '0'
|
||||||
const u = ['B','KB','MB','GB','TB']; let i=0,v=n
|
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-option v-for="i in instances" :key="i.id" :value="i.id" :label="`${i.name} (${i.proto}/${i.port})`" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-col>
|
</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="4"><el-button @click="loadUsers">刷新</el-button></el-col>
|
||||||
<el-col :span="6" style="text-align:right">
|
<el-col :span="6" style="text-align:right">
|
||||||
<el-button type="primary" :disabled="!instanceId" @click="openCreate"><el-icon><Plus /></el-icon>新建用户</el-button>
|
<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
|
sleep 1
|
||||||
done
|
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
|
cat <<EOF
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user