package openvpn import ( "encoding/base64" "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) } 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 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 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) } }