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参数)
137 lines
4.0 KiB
Go
137 lines
4.0 KiB
Go
package openvpn
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/pquerna/otp/totp"
|
|
|
|
"vpn-platform/backend/internal/model"
|
|
)
|
|
|
|
func TestValidateOpenVPNModes(t *testing.T) {
|
|
cfg, err := New().Validate(map[string]interface{}{"auth_mode": "password", "subnet": "10.8.0.0 255.255.255.0"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg["proto"] != "udp" || cfg["port"] != 1194 {
|
|
t.Fatalf("defaults not applied: %#v", cfg)
|
|
}
|
|
if _, err := New().Validate(map[string]interface{}{"auth_mode": "unknown"}); err == nil {
|
|
t.Fatal("expected invalid auth mode error")
|
|
}
|
|
// 前端历史值 tcp 应归一为 tcp-server
|
|
cfg2, err := New().Validate(map[string]interface{}{"proto": "tcp", "subnet": "10.8.0.0 255.255.255.0"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg2["proto"] != "tcp-server" {
|
|
t.Fatalf("tcp not normalized: %#v", cfg2)
|
|
}
|
|
cfg3, err := New().Validate(map[string]interface{}{
|
|
"subnet": "10.8.0.0 255.255.255.0", "push_routes": "192.168.1.0/24",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg3["redirect_gateway"] != false {
|
|
t.Fatalf("push_routes should default redirect_gateway off: %#v", cfg3)
|
|
}
|
|
// MFA 仅允许密码相关模式
|
|
if _, err := New().Validate(map[string]interface{}{
|
|
"auth_mode": "certificate", "mfa_mode": "required", "subnet": "10.8.0.0 255.255.255.0",
|
|
}); err == nil {
|
|
t.Fatal("expected MFA + certificate-only to fail")
|
|
}
|
|
cfg4, err := New().Validate(map[string]interface{}{
|
|
"auth_mode": "password", "mfa_mode": "required", "subnet": "10.8.0.0 255.255.255.0", "proto": "tcp",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg4["proto"] != "tcp-server" || cfg4["mfa_mode"] != "required" {
|
|
t.Fatalf("mfa/tcp normalize: %#v", cfg4)
|
|
}
|
|
}
|
|
|
|
func TestSplitPasswordAndOTP(t *testing.T) {
|
|
pw, otp := splitPasswordAndOTP("secretpass123456", "")
|
|
if pw != "secretpass" || otp != "123456" {
|
|
t.Fatalf("suffix: %q %q", pw, otp)
|
|
}
|
|
scrv := "SCRV1:" + base64.StdEncoding.EncodeToString([]byte("mypass")) + ":" + base64.StdEncoding.EncodeToString([]byte("654321"))
|
|
pw, otp = splitPasswordAndOTP(scrv, "")
|
|
if pw != "mypass" || otp != "654321" {
|
|
t.Fatalf("scrv1: %q %q", pw, otp)
|
|
}
|
|
pw, otp = splitPasswordAndOTP("onlypass", "111222")
|
|
if pw != "onlypass" || otp != "111222" {
|
|
t.Fatalf("challenge line: %q %q", pw, otp)
|
|
}
|
|
}
|
|
|
|
func TestClientProto(t *testing.T) {
|
|
if clientProto("tcp-server") != "tcp-client" || clientProto("udp") != "udp" || clientProto("tcp") != "tcp-client" {
|
|
t.Fatal("clientProto mapping failed")
|
|
}
|
|
}
|
|
|
|
func TestServerConfigIncludesProductionTunings(t *testing.T) {
|
|
cfg, err := New().Validate(map[string]interface{}{
|
|
"auth_mode": "password", "proto": "tcp", "subnet": "10.8.0.0 255.255.240.0",
|
|
"max_clients": 1000, "mfa_mode": "off",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg["proto"] != "tcp-server" {
|
|
t.Fatalf("proto: %#v", cfg["proto"])
|
|
}
|
|
tunn := &model.Tunnel{ID: 1, Protocol: "openvpn"}
|
|
raw := New().serverConfig(tunn, cfg)
|
|
for _, want := range []string{
|
|
"proto tcp-server",
|
|
"tun-mtu 1500",
|
|
"mssfix 1450",
|
|
"keepalive 5 30",
|
|
"reneg-sec 0",
|
|
"verify-client-cert none",
|
|
"username-as-common-name",
|
|
"max-clients 1000",
|
|
"duplicate-cn",
|
|
"via-file",
|
|
} {
|
|
if !strings.Contains(raw, want) {
|
|
t.Fatalf("server.conf missing %q\n%s", want, raw)
|
|
}
|
|
}
|
|
if strings.Contains(raw, "via-env") {
|
|
t.Fatal("should use via-file not via-env")
|
|
}
|
|
}
|
|
|
|
func TestValidateTOTPSkew(t *testing.T) {
|
|
// 使用固定 secret 生成当前码并校验
|
|
secret := "JBSWY3DPEHPK3PXP" // "Hello!" base32, common test secret
|
|
code, err := totp.GenerateCode(secret, time.Now())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ok, counter := validateTOTPSkew(code, secret, 1)
|
|
if !ok || counter == 0 {
|
|
t.Fatalf("expected valid totp, ok=%v counter=%d", ok, counter)
|
|
}
|
|
if ok2, _ := validateTOTPSkew("000000", secret, 1); ok2 {
|
|
t.Fatal("expected invalid code")
|
|
}
|
|
}
|
|
|
|
func TestCertificateCNIsSafeAndStable(t *testing.T) {
|
|
cn := certificateCN("张三 / alice@example.com")
|
|
if !certificateName.MatchString(cn) || !strings.HasPrefix(cn, "u-") {
|
|
t.Fatalf("unsafe CN: %q", cn)
|
|
}
|
|
}
|