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:
1 parent
58c036c34c
commit
1e2f8ef24f
43 files changed
+6783
-460
No files matched your search
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user