feat: 全面优化 — UI/UX重构 + 流量审计与会话管理 + 协议部署

UI/UX 优化:
- 登录页分屏布局 + 可访问性修复 + 内联错误提示
- 侧栏菜单分组(专线运维/资源管理/系统) + 图标修正 + 子路由高亮
- Dashboard 统计卡渐变图标 + 空状态引导 + 统计卡可点击
- 创建向导: 依赖检测门控 + 高级折叠 + 必填校验 + WireGuard推荐标
- 专线列表: 空状态CTA + 操作列「更多」下拉 + 状态筛选 + SSE日志流
- 协议品牌SVG图标(ProtocolIcon组件)替换emoji
- 设计令牌系统(tokens.css)统一颜色/间距/圆角/阴影

功能增强:
- 在线会话管理(sessionmgr): 跨协议会话同步 + 踢下线 + 连接审计日志
- 流量配额引擎(trafficmgr): 配额检查 + 超额动作(block/warn/throttle) + 周期重置
- 异常流量告警: 突发流量检测(>90MB/15s) + 审计记录
- 节点SSH凭证管理: 密码/私钥认证 + 即时测试连接 + AES-GCM加密存储
- 协议二进制管理(binmgr): FRP/OpenVPN GitHub下载 + 系统包安装引导(apt/yum/dnf)
- 右上角修改密码弹窗 + MFA独立验证页 + 密码强度提示
- 用户管理: 批量导入 + LDAP浏览导入 + 平台用户导入VPN拨号用户
- 404路由 + IPSec对接页可达性修复
- MFA备用码持久化弹窗(替代Toast)
- 轮询可见性守卫 + ECharts resize修复

后端:
- 3张新表: VPNSession/ConnectionLog/TrafficPolicy
- monitor增强: 会话同步+配额检查+异常告警+到期审计
- changePassword统一8位+新旧不同校验
- openvpn-otp文案专业化(移除抄袭感)
- SSE日志流(token降级query参数)
This commit is contained in:
Logan.Li committed 2026-07-23 01:42:36 +08:00
1 parent 58c036c34c
commit 1e2f8ef24f
43 files changed
+6783 -460

No files matched your search

+150 -5
View File
@@ -6,8 +6,11 @@ package wireguard
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
@@ -140,8 +143,16 @@ func (s *Service) Validate(cfg map[string]interface{}) (map[string]interface{},
if egress := service.Str(out, "egress_interface"); egress != "" && !ifacePattern.MatchString(egress) {
return nil, fmt.Errorf("egress_interface 包含非法字符")
}
// 启用 NAT 但未指定出口时,自动探测默认路由网卡;探测失败再要求手填。
if service.Bool(out, "enable_nat", false) && service.Str(out, "egress_interface") == "" {
return nil, fmt.Errorf("启用 NAT 时必须填写 egress_interface")
iface, err := service.DefaultRouteInterface()
if err != nil {
return nil, fmt.Errorf("启用 NAT 时无法自动探测出口网卡,请填写 egress_interface: %w", err)
}
out["egress_interface"] = iface
}
if mtu := int(service.Num(out, "mtu", 0)); mtu != 0 && (mtu < 576 || mtu > 9000) {
return nil, fmt.Errorf("mtu 必须在 576-9000 之间")
}
if _, supplied := out["peers"]; supplied {
peers, err := parsePeers(out)
@@ -286,18 +297,22 @@ func generateClientConfig(cfg map[string]interface{}, peers []peer, peerSecrets
if allowed == "" {
allowed = service.StrOr(cfg, "allowed_ips", "0.0.0.0/0, ::/0")
}
mtuLine := ""
if mtu := int(service.Num(cfg, "mtu", 0)); mtu > 0 {
mtuLine = fmt.Sprintf("MTU = %d\n", mtu)
}
return fmt.Sprintf(`[Interface]
PrivateKey = %s
Address = %s
DNS = %s
%s
[Peer]
PublicKey = %s
PresharedKey = %s
Endpoint = %s
AllowedIPs = %s
PersistentKeepalive = %d
`, sec.PrivateKey, p.ClientAddress, service.StrOr(cfg, "dns", "223.5.5.5"), service.Str(cfg, "server_public_key"), sec.PresharedKey, endpoint, allowed, int(service.Num(cfg, "keepalive", 25))), nil
`, sec.PrivateKey, p.ClientAddress, service.StrOr(cfg, "dns", "223.5.5.5"), mtuLine, service.Str(cfg, "server_public_key"), sec.PresharedKey, endpoint, allowed, int(service.Num(cfg, "keepalive", 25))), nil
}
if requestedID != "" {
return "", fmt.Errorf("WireGuard Peer 不存在: %s", requestedID)
@@ -354,7 +369,11 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
for _, p := range peers {
byKey[p.PublicKey] = p
}
// 内核计数器在接口重启后归零;用 traffic-state 做增量,再叠到库中的累计值。
prev := loadTrafficState(t)
next := map[string][2]int64{}
st := service.NowStatus(true, "running", "WireGuard 接口运行中")
var deltaRx, deltaTx int64
connected := 0
for _, line := range strings.Split(strings.TrimSpace(dump), "\n") {
fields := strings.Split(line, "\t")
@@ -374,13 +393,25 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
if alive {
connected++
}
st.RxBytes += rx
st.TxBytes += tx
old := prev[fields[0]]
dRx, dTx := rx-old[0], tx-old[1]
if dRx < 0 {
dRx = rx
}
if dTx < 0 {
dTx = tx
}
deltaRx += dRx
deltaTx += dTx
next[fields[0]] = [2]int64{rx, tx}
st.Peers = append(st.Peers, core.PeerStatus{
ID: p.ID, Name: p.Name, PublicKey: fields[0], Endpoint: fields[2], AllowedIPs: fields[3],
LatestHandshakeAt: handshake, Connected: alive, RxBytes: rx, TxBytes: tx,
})
}
saveTrafficState(t, next)
st.RxBytes = t.RxBytes + deltaRx
st.TxBytes = t.TxBytes + deltaTx
sort.Slice(st.Peers, func(i, j int) bool { return st.Peers[i].ID < st.Peers[j].ID })
st.Detail = fmt.Sprintf("WireGuard 接口 %s 运行中,%d/%d 个 Peer 最近已握手", interfaceName(t), connected, len(st.Peers))
return st, nil
@@ -388,6 +419,117 @@ func (s *Service) Status(t *model.Tunnel) (core.Status, error) {
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
// AddPeer appends a managed or external peer and rewrites configuration.
func (s *Service) AddPeer(t *model.Tunnel, input map[string]interface{}) (peer, error) {
cfg, err := store.TunnelConfig(t)
if err != nil {
return peer{}, err
}
peers, err := parsePeers(cfg)
if err != nil {
return peer{}, err
}
p := peer{
ID: service.Str(input, "id"),
Name: service.Str(input, "name"),
PublicKey: service.Str(input, "public_key"),
ClientAddress: service.Str(input, "client_address"),
AllowedIPs: service.Str(input, "allowed_ips"),
Enabled: true,
Managed: service.Str(input, "public_key") == "",
}
if p.ID == "" {
p.ID = fmt.Sprintf("client-%d", len(peers)+1)
}
if p.Name == "" {
p.Name = p.ID
}
if p.ClientAddress == "" {
return peer{}, fmt.Errorf("client_address 必填")
}
peers = append(peers, p)
cfg["peers"] = peersToConfig(peers)
raw, _ := json.Marshal(cfg)
t.Config = string(raw)
if err := s.Create(t); err != nil {
return peer{}, err
}
// Return the persisted peer (with generated public key when managed).
cfg, _ = store.TunnelConfig(t)
peers, _ = parsePeers(cfg)
for _, item := range peers {
if item.ID == p.ID {
return item, nil
}
}
return p, nil
}
// RemovePeer deletes one peer by id and rewrites configuration.
func (s *Service) RemovePeer(t *model.Tunnel, peerID string) error {
cfg, err := store.TunnelConfig(t)
if err != nil {
return err
}
peers, err := parsePeers(cfg)
if err != nil {
return err
}
next := make([]peer, 0, len(peers))
found := false
for _, p := range peers {
if p.ID == peerID {
found = true
continue
}
next = append(next, p)
}
if !found {
return fmt.Errorf("Peer 不存在: %s", peerID)
}
if len(next) == 0 {
return fmt.Errorf("至少保留一个 Peer")
}
cfg["peers"] = peersToConfig(next)
// Drop removed peer secrets so keys are not retained after revocation.
secrets, _ := store.TunnelSecrets(t)
if raw, ok := secrets["peer_secrets"].(map[string]interface{}); ok {
delete(raw, peerID)
secrets["peer_secrets"] = raw
_ = service.SaveTunnelConfig(t, cfg, secrets)
}
raw, _ := json.Marshal(cfg)
t.Config = string(raw)
return s.Create(t)
}
func loadTrafficState(t *model.Tunnel) map[string][2]int64 {
out := map[string][2]int64{}
raw, err := os.ReadFile(filepath.Join(service.InstanceDir(t), "wg-traffic-state.json"))
if err != nil {
return out
}
var m map[string][]int64
if json.Unmarshal(raw, &m) != nil {
return out
}
for k, v := range m {
if len(v) >= 2 {
out[k] = [2]int64{v[0], v[1]}
}
}
return out
}
func saveTrafficState(t *model.Tunnel, state map[string][2]int64) {
m := make(map[string][]int64, len(state))
for k, v := range state {
m[k] = []int64{v[0], v[1]}
}
raw, _ := json.Marshal(m)
_ = os.WriteFile(filepath.Join(service.InstanceDir(t), "wg-traffic-state.json"), raw, 0o600)
}
// interfaceName is deterministic, <= IFNAMSIZ-1 (15) and does not depend on
// the user supplied tunnel name. This prevents cross-tunnel interface clashes.
func interfaceName(t *model.Tunnel) string { return "wg" + strconv.FormatUint(uint64(t.ID), 36) }
@@ -640,6 +782,9 @@ func peerSecretsToConfig(secrets map[string]peerSecret) map[string]interface{} {
func renderServerConfig(cfg map[string]interface{}, serverPrivate string, peers []peer, secrets map[string]peerSecret) string {
var b strings.Builder
fmt.Fprintf(&b, "[Interface]\nPrivateKey = %s\nAddress = %s\nListenPort = %d\n", serverPrivate, service.Str(cfg, "address"), int(service.Num(cfg, "listen_port", defaultPort)))
if mtu := int(service.Num(cfg, "mtu", 0)); mtu > 0 {
fmt.Fprintf(&b, "MTU = %d\n", mtu)
}
if service.Bool(cfg, "enable_nat", false) {
egress := service.Str(cfg, "egress_interface")
fmt.Fprintf(&b, "PostUp = iptables -A FORWARD -i %%i -j ACCEPT; iptables -A FORWARD -o %%i -j ACCEPT; iptables -t nat -A POSTROUTING -o %s -j MASQUERADE\n", egress)
@@ -8,6 +8,7 @@ import (
"testing"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/store"
)
@@ -67,8 +68,11 @@ func TestBuildPeersKeepsExternalPeerPrivateKeyOutOfPlatform(t *testing.T) {
func TestValidateRejectsUnsafeNATAndInvalidPeerAddress(t *testing.T) {
s := New()
if _, err := s.Validate(map[string]interface{}{"address": "10.8.0.1/24", "enable_nat": true}); err == nil {
t.Fatal("NAT without explicit egress interface was accepted")
// enable_nat without egress:若本机无默认路由则失败;有默认路由则自动填 egress_interface。
if cfg, err := s.Validate(map[string]interface{}{"address": "10.8.0.1/24", "enable_nat": true}); err == nil {
if service.Str(cfg, "egress_interface") == "" {
t.Fatal("NAT auto-detect succeeded but egress_interface is empty")
}
}
_, err := s.Validate(map[string]interface{}{
"address": "10.8.0.1/24",