package openvpn import ( "encoding/base64" "fmt" "os" "path/filepath" "strings" "testing" "time" "github.com/glebarez/sqlite" "github.com/pquerna/otp/totp" "gorm.io/gorm" "vpn-platform/backend/internal/model" "vpn-platform/backend/internal/service" "vpn-platform/backend/internal/store" ) func TestOIDCConfigAndSecrets(t *testing.T) { t.Setenv("VPN_DATA_DIR", t.TempDir()) tunnel := &model.Tunnel{ID: 42, Protocol: "openvpn"} input := map[string]interface{}{ "auth_mode": "oidc", "oidc_issuer": "https://idp.example.com/realms/team", "oidc_base_url": "https://vpn-login.example.com", "oidc_client_id": "vpn-client", "oidc_client_secret": "secret-\nadmin: true", "oidc_http_port": 19242, } cfg, err := New().Validate(input) if err != nil { t.Fatal(err) } if err := prepareOIDC(tunnel, cfg); err != nil { t.Fatal(err) } conf := New().serverConfig(tunnel, cfg) for _, required := range []string{"management-client-auth", "auth-user-pass-optional", "verify-client-cert none"} { if !strings.Contains(conf, required) { t.Fatalf("OIDC server config missing %q", required) } } if strings.Contains(conf, "auth-user-pass-verify") || strings.Contains(conf, "secret-") { t.Fatal("OIDC server config must not use password helper or leak secrets") } path := filepath.Join(service.InstanceDir(tunnel), "openvpn-auth-oauth2.yaml") bytes, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if !strings.Contains(string(bytes), `secret: "secret-\nadmin: true"`) || strings.Contains(string(bytes), "\nadmin: true\n") { t.Fatal("OIDC client secret must remain one quoted YAML value") } if stat, err := os.Stat(path); err != nil || stat.Mode().Perm() != 0o600 { t.Fatalf("auth configuration must be 0600: %v %v", stat, err) } pagePath := filepath.Join(service.InstanceDir(tunnel), "auth-result.html") page, err := os.ReadFile(pagePath) if err != nil { t.Fatal(err) } if stat, err := os.Stat(pagePath); err != nil || stat.Mode().Perm() != 0o600 { t.Fatalf("auth result template must be 0600: %v %v", stat, err) } for _, want := range []string{"name=\"viewport\"", "请返回 VPN 客户端", "请在 VPN 客户端查看连接状态"} { if !strings.Contains(string(page), want) { t.Fatalf("auth result template missing %q", want) } } passwordPath := filepath.Join(service.InstanceDir(tunnel), "management-password.txt") if stat, err := os.Stat(passwordPath); err != nil || stat.Mode().Perm() != 0o600 { t.Fatalf("management password must be 0600: %v %v", stat, err) } public, secrets := service.MergeSecrets(cfg, "oidc_client_secret", "oidc_http_secret", "oidc_management_password") if public["oidc_client_secret"] != nil || secrets["oidc_client_secret"] == nil { t.Fatal("OIDC credentials must not be kept in public config") } tunnel.Config = service.ConfigJSON(public) dir := service.InstanceDir(tunnel) if err := os.MkdirAll(filepath.Join(dir, "pki"), 0o700); err != nil { t.Fatal(err) } for path, content := range map[string]string{"pki/ca.crt": "test-ca\n", "ta.key": "test-tls-key\n"} { if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0o600); err != nil { t.Fatal(err) } } client, err := New().GenerateClientConfig(tunnel) if err != nil { t.Fatal(err) } if strings.Contains(client, "auth-user-pass") || strings.Contains(client, "") || !strings.Contains(client, "") { t.Fatal("OIDC client profile must use WebAuth without password or client certificate") } } func TestBuiltInOIDCUsesStableSubjectAsOpenVPNUsername(t *testing.T) { tunnel := &model.Tunnel{ID: 44, Protocol: "openvpn"} config := map[string]interface{}{ "oidc_provider": "builtin", "oidc_issuer": "https://vpn.example.com/id", } generated := oidcConfig(tunnel, config) if !strings.Contains(generated, `openvpn-username: "string(token.claims.sub)"`) { t.Fatalf("built-in issuer must use verified subject as username: %s", generated) } config["oidc_provider"] = "external" if generated := oidcConfig(tunnel, config); strings.Contains(generated, "openvpn-username:") { t.Fatalf("external issuer username behavior must remain unchanged: %s", generated) } } func TestOIDCRejectsUnsafeURLs(t *testing.T) { base := map[string]interface{}{ "auth_mode": "oidc", "oidc_issuer": "https://idp.example.com/realms/a", "oidc_base_url": "https://login.example.com", "oidc_client_id": "vpn", } for _, value := range []string{"http://login.example.com", "https://login.example.com/extra", "https://user@login.example.com", "https://login.example.com/?code=abc"} { cfg := service.CloneConfig(base) cfg["oidc_base_url"] = value if _, err := New().Validate(cfg); err == nil { t.Errorf("expected invalid OIDC callback base URL: %q", value) } } if _, err := New().Validate(base); err != nil { t.Fatal(err) } base["auth_mode"] = "password" base["oidc_client_secret"] = "must-not-survive" cfg, err := New().Validate(base) if err != nil { t.Fatal(err) } if _, exists := cfg["oidc_client_secret"]; exists { t.Fatal("switching away from OIDC must remove its credentials") } } func TestSwitchingAwayFromBuiltInCannotReuseItsClientSecret(t *testing.T) { dir := t.TempDir() t.Setenv("VPN_DATA_DIR", dir) if err := store.Init(dir); err != nil { t.Fatal(err) } secret, err := store.EncryptSecret(`{"oidc_client_secret":"old-built-in-secret"}`) if err != nil { t.Fatal(err) } tunnel := &model.Tunnel{ID: 43, Protocol: "openvpn", Config: `{"oidc_provider":"builtin"}`, Secrets: secret} cfg := map[string]interface{}{ "oidc_provider": "external", "oidc_issuer": "https://external.example.com/id", "oidc_base_url": "https://login.example.com", "oidc_client_id": "external-client", } if err := prepareOIDC(tunnel, cfg); err == nil { t.Fatal("external provider reused a built-in client secret") } cfg["oidc_provider"] = "builtin" cfg["oidc_client_secret"] = "forged" if err := prepareOIDC(tunnel, cfg); err == nil { t.Fatal("built-in provider accepted an unregistered issuer and client") } } func TestOIDCRequiresOpenVPN262(t *testing.T) { for _, version := range []string{"OpenVPN 2.5.11 x86_64", "OpenVPN 2.6.1 x86_64", "unknown"} { if err := validateOIDCOpenVPNVersion([]byte(version)); err == nil { t.Errorf("expected %q to fail minimum version check", version) } } for _, version := range []string{"OpenVPN 2.6.2 x86_64", "OpenVPN 2.7.0 x86_64", "OpenVPN 3.0.0 x86_64"} { if err := validateOIDCOpenVPNVersion([]byte(version)); err != nil { t.Errorf("expected %q to pass: %v", version, err) } } } func TestOIDCTunnelsNeedDistinctCallbackAndPort(t *testing.T) { db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{}) if err != nil { t.Fatal(err) } oldDB := store.DB store.DB = db t.Cleanup(func() { store.DB = oldDB }) if err := db.AutoMigrate(&model.Tunnel{}); err != nil { t.Fatal(err) } first := model.Tunnel{Name: "first", Protocol: "openvpn", Config: service.ConfigJSON(map[string]interface{}{ "auth_mode": "oidc", "oidc_base_url": "https://login.example.com", "oidc_http_port": 19090, })} if err := db.Create(&first).Error; err != nil { t.Fatal(err) } other := &model.Tunnel{ID: first.ID + 1, Protocol: "openvpn"} for _, cfg := range []map[string]interface{}{ {"oidc_base_url": "https://login.example.com/", "oidc_http_port": 19091}, {"oidc_base_url": "https://another.example.com", "oidc_http_port": 19090}, } { if err := checkOIDCConflicts(other, cfg); err == nil { t.Errorf("expected conflict: %#v", cfg) } } if err := checkOIDCConflicts(other, map[string]interface{}{"oidc_base_url": "https://another.example.com", "oidc_http_port": 19091}); err != nil { t.Fatal(err) } } 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"] != "tcp-server" || cfg["port"] != 1194 || cfg["auth_mode"] != "password" { t.Fatalf("defaults not applied: %#v", cfg) } if cfg["enable_nat"] != true { t.Fatalf("NAT should default to enabled: %#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": "# VPC\n192.168.1.0/24\n# staging\n10.0.0.10", }) if err != nil { t.Fatal(err) } if cfg3["redirect_gateway"] != false { t.Fatalf("push_routes should default redirect_gateway off: %#v", cfg3) } if cfg3["push_routes"] != "# VPC\n192.168.1.0/24\n# staging\n10.0.0.10/32" { t.Fatalf("push_routes not normalized: %#v", cfg3) } if _, err := New().Validate(map[string]interface{}{"subnet": "10.8.0.0 255.255.255.0", "push_routes": "10.0.0.999"}); err == nil { t.Fatal("expected invalid route to fail") } // 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", "data-ciphers-fallback AES-256-GCM", } { 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") } for _, obsolete := range []string{"persist-key", "ifconfig-pool-persist"} { if strings.Contains(raw, obsolete) { t.Fatalf("server.conf should not contain obsolete %q\n%s", obsolete, raw) } } } 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) } } func TestPKIScriptUses99YearCertificates(t *testing.T) { script := pkiScript(&model.Tunnel{ID: 1, Protocol: "openvpn"}) for _, want := range []string{"EASYRSA_CA_EXPIRE=36135", "EASYRSA_CERT_EXPIRE=36135"} { if !strings.Contains(script, want) { t.Fatalf("PKI script missing %q\n%s", want, script) } } if !strings.Contains(script, "EASYRSA_REQ_CN=OPSX-VPN-CA") { t.Fatalf("PKI script should set a CA name: %s", script) } if strings.Contains(script, "export EASYRSA_BATCH=1 EASYRSA_REQ_CN") { t.Fatalf("PKI script must not export the CA CN to server/client certificate commands: %s", script) } } func TestSplitStatusFieldsSupportsOpenVPNStatusV3(t *testing.T) { line := "CLIENT_LIST\tadmin\ttcp4-server:203.0.113.1:1194\t10.8.0.2\t\t7161451\t8489433\t2026-07-25 23:32:58\t1784993578\tadmin" fields := splitStatusFields(line) if len(fields) != 10 || fields[5] != "7161451" || fields[9] != "admin" { t.Fatalf("status-version 3 fields parsed incorrectly: %#v", fields) } } func TestStatusUsernameDoesNotTreatUNDEFAsIdentity(t *testing.T) { for _, tc := range []struct { name, cn, username, want string }{ {"password", "admin", "admin", "admin"}, {"authenticated username", "UNDEF", "tenant:ou_123", "tenant:ou_123"}, {"certificate", "client-cert", "UNDEF", "client-cert"}, {"webauth without username", "UNDEF", "UNDEF", ""}, {"webauth empty username", "UNDEF", "", ""}, {"webauth lowercase placeholder", "undef", "UnDeF", ""}, } { t.Run(tc.name, func(t *testing.T) { fields := splitStatusFields("CLIENT_LIST\t" + tc.cn + "\t203.0.113.1:1234\t10.8.0.2\t\t12\t34\t2026-10-22\t1234\t" + tc.username) if got := statusUsername(fields); got != tc.want { t.Fatalf("statusUsername() = %q, want %q", got, tc.want) } }) } } func TestWebAuthSessionIdentityAndTrafficTransition(t *testing.T) { dir := t.TempDir() t.Setenv("VPN_DATA_DIR", dir) previousDB := store.DB t.Cleanup(func() { if store.DB != nil { if sqlDB, err := store.DB.DB(); err == nil { _ = sqlDB.Close() } } store.DB = previousDB }) if err := store.Init(dir); err != nil { t.Fatal(err) } tunnel := &model.Tunnel{ID: 502, Name: "webauth-test", Protocol: "openvpn"} if err := store.DB.Create(tunnel).Error; err != nil { t.Fatal(err) } for _, username := range []string{"UNDEF", "tenant:ou_123"} { if err := store.DB.Create(&model.VPNUser{TunnelID: tunnel.ID, Username: username, Enabled: true}).Error; err != nil { t.Fatal(err) } } instanceDir := service.InstanceDir(tunnel) if err := os.MkdirAll(instanceDir, 0o700); err != nil { t.Fatal(err) } writeStatus := func(username string, rx, tx int64) { t.Helper() line := fmt.Sprintf("CLIENT_LIST\tUNDEF\t203.0.113.1:1234\t10.8.0.2\t\t%d\t%d\t2026-10-22\t1234\t%s\n", rx, tx, username) if err := os.WriteFile(filepath.Join(instanceDir, "openvpn-status.log"), []byte(line), 0o600); err != nil { t.Fatal(err) } } writeStatus("UNDEF", 150, 230) svc := New() if rx, tx := svc.collectTraffic(tunnel); rx != 150 || tx != 230 { t.Fatalf("initial traffic = %d/%d", rx, tx) } var unknown model.VPNSession if err := store.DB.Where("tunnel_id = ? AND status = ?", tunnel.ID, "active").First(&unknown).Error; err != nil || unknown.Username != "" || unknown.VPNUserID != 0 { t.Fatalf("unknown peer must not be attributed: %+v, %v", unknown, err) } var placeholder model.VPNUser store.DB.Where("tunnel_id = ? AND username = ?", tunnel.ID, "UNDEF").First(&placeholder) if placeholder.RxBytes != 0 || placeholder.TxBytes != 0 { t.Fatal("UNDEF placeholder received VPN user traffic") } if rx, tx := svc.collectTraffic(tunnel); rx != 0 || tx != 0 { t.Fatalf("unchanged snapshot counted twice: %d/%d", rx, tx) } writeStatus("tenant:ou_123", 160, 260) if rx, tx := svc.collectTraffic(tunnel); rx != 10 || tx != 30 { t.Fatalf("authenticated snapshot delta = %d/%d, want 10/30", rx, tx) } var identified model.VPNSession if err := store.DB.Where("tunnel_id = ? AND status = ?", tunnel.ID, "active").First(&identified).Error; err != nil || identified.Username != "tenant:ou_123" || identified.VPNUserID == 0 { t.Fatalf("verified username should link its own user: %+v, %v", identified, err) } }