Files
opsx-vpn/backend/internal/service/openvpn/openvpn_test.go
T

313 lines
11 KiB
Go

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, "<cert>") || !strings.Contains(client, "<tls-crypt>") {
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)
}
}