// SPDX-License-Identifier: Apache-2.0 package server import ( "encoding/json" "net/http" "net/http/httptest" "path/filepath" "reflect" "testing" "github.com/X4Applegate/caddyui/internal/caddy" appdb "github.com/X4Applegate/caddyui/internal/db" "github.com/X4Applegate/caddyui/internal/dns" "github.com/X4Applegate/caddyui/internal/models" ) func TestHTTPRoutesReplaceUnsupportedSkipRedirectsWithoutPortConflict(t *testing.T) { conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = conn.Close() }) s := &Server{DB: conn} proxies := []models.ProxyHost{ {Domains: "open.example.com", Enabled: true, SSLEnabled: true, SSLForced: false, ForwardScheme: "http", ForwardHost: "app", ForwardPort: 8080}, {Domains: "forced.example.com", Enabled: true, SSLEnabled: true, SSLForced: true, ForwardScheme: "http", ForwardHost: "app", ForwardPort: 8080}, } routes := s.buildHTTPRoutes(proxies, nil, nil) if len(routes) != 3 { // v2.59.4: open route, forced redirect, 404 fallback t.Fatalf("HTTP routes = %d, want open route plus forced redirect plus the fallback", len(routes)) } cfg := map[string]any{ "apps": map[string]any{ "http": map[string]any{ "servers": map[string]any{ "srv0": map[string]any{ "automatic_https": map[string]any{ "skip_redirects": []any{"open.example.com"}, "skip_certificates": []any{"plain.example.com"}, }, }, }, }, }, } removeUnsupportedSkipRedirects(cfg) applyPlainHTTPServer(cfg, routes) applyDisableAutomaticHTTPSRedirects(cfg, true) servers := cfg["apps"].(map[string]any)["http"].(map[string]any)["servers"].(map[string]any) auto := servers["srv0"].(map[string]any)["automatic_https"].(map[string]any) if _, exists := auto["skip_redirects"]; exists { t.Fatal("unsupported automatic_https.skip_redirects was not removed") } if disabled, _ := auto["disable_redirects"].(bool); !disabled { t.Fatal("automatic HTTPS redirects were not disabled") } httpServer := servers["caddyui_http"].(map[string]any) if !reflect.DeepEqual(httpServer["listen"], []any{":80"}) { t.Fatalf("HTTP listen = %#v, want [:80]", httpServer["listen"]) } redirect := routes[1].(map[string]any) handler := redirect["handle"].([]any)[0].(map[string]any) if handler["status_code"] != 308 { t.Fatalf("forced HTTP status = %#v, want 308", handler["status_code"]) } } func TestHTTPRoutesIncludeRawRouteForceSSLBehavior(t *testing.T) { conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = conn.Close() }) s := &Server{DB: conn} raws := []models.RawRoute{ {Enabled: true, ForceSSL: false, JSONData: `{"match":[{"host":["open-raw.example.com"]}],"handle":[{"handler":"static_response","status_code":200}]}`}, {Enabled: true, ForceSSL: true, JSONData: `{"match":[{"host":["forced-raw.example.com"]}],"handle":[{"handler":"static_response","status_code":200}]}`}, } routes := s.buildHTTPRoutes(nil, nil, raws) if len(routes) != 3 { // v2.59.4: open raw route, forced redirect, 404 fallback t.Fatalf("HTTP routes = %d, want open raw route plus forced redirect plus the fallback", len(routes)) } redirect := routes[1].(map[string]any) match := redirect["match"].([]any)[0].(map[string]any) if !reflect.DeepEqual(match["host"], []any{"forced-raw.example.com"}) { t.Fatalf("forced raw redirect hosts = %#v", match["host"]) } } func TestCaddyDNSProviderConfig(t *testing.T) { tests := []struct { name string provider string creds map[string]string want map[string]any }{ {"cloudflare", dns.Cloudflare, map[string]string{"cf_api_token": "token"}, map[string]any{"name": "cloudflare", "api_token": "token"}}, {"porkbun", dns.Porkbun, map[string]string{"pb_api_key": "key", "pb_secret_key": "secret"}, map[string]any{"name": "porkbun", "api_key": "key", "api_secret_key": "secret"}}, {"namecheap", dns.Namecheap, map[string]string{"nc_api_user": "user", "nc_api_key": "key", "nc_client_ip": "192.0.2.1"}, map[string]any{"name": "namecheap", "user": "user", "api_key": "key", "client_ip": "192.0.2.1"}}, {"godaddy", dns.GoDaddy, map[string]string{"gd_api_key": "key", "gd_api_secret": "secret"}, map[string]any{"name": "godaddy", "api_token": "key:secret"}}, {"digitalocean", dns.DigitalOcean, map[string]string{"do_api_token": "token"}, map[string]any{"name": "digitalocean", "auth_token": "token"}}, {"hetzner", dns.Hetzner, map[string]string{"hetzner_api_token": "token"}, map[string]any{"name": "hetzner", "api_token": "token"}}, {"gandi", dns.Gandi, map[string]string{"gandi_api_token": " pat "}, map[string]any{"name": "gandi", "bearer_token": "pat"}}, {"gandi-blank", dns.Gandi, map[string]string{"gandi_api_token": ""}, nil}, {"route53", dns.Route53, map[string]string{"route53_access_key_id": "AKIAEXAMPLE", "route53_secret_access_key": "secret"}, map[string]any{"name": "route53", "access_key_id": "AKIAEXAMPLE", "secret_access_key": "secret", "region": "us-east-1"}}, {"route53-temporary", dns.Route53, map[string]string{"route53_access_key_id": "AKIAEXAMPLE", "route53_secret_access_key": "secret", "route53_session_token": "session", "route53_region": "us-gov-west-1"}, map[string]any{"name": "route53", "access_key_id": "AKIAEXAMPLE", "secret_access_key": "secret", "session_token": "session", "region": "us-gov-west-1"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := caddyDNSProviderConfig(tt.provider, tt.creds, ""); !reflect.DeepEqual(got, tt.want) { t.Fatalf("config = %#v, want %#v", got, tt.want) } }) } } func TestRoute53ProviderConfigPinsSelectedHostedZone(t *testing.T) { got := caddyDNSProviderConfig(dns.Route53, map[string]string{ "route53_access_key_id": "AKIAEXAMPLE", "route53_secret_access_key": "secret", }, "Z0123456789") if got["hosted_zone_id"] != "Z0123456789" { t.Fatalf("hosted_zone_id = %#v, want selected zone", got["hosted_zone_id"]) } } func TestDNSAutomationPoliciesSeparateRoute53ZonesAndKeepDNSOnlyHosts(t *testing.T) { conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = conn.Close() }) for key, value := range map[string]string{ settingRoute53AccessKeyID: "AKIAEXAMPLE", settingRoute53SecretAccessKey: "secret", } { if err := models.SetSetting(conn, key, value); err != nil { t.Fatal(err) } } s := &Server{DB: conn} policies := s.buildDNSAutomationPolicies([]models.ProxyHost{ {Domains: "public.example.com", Enabled: true, SSLEnabled: true, DNSProvider: dns.Route53, DNSZoneID: "ZPUBLIC"}, {Domains: "internal.example.net", Enabled: true, SSLEnabled: true, DNSProvider: dns.Route53, DNSZoneID: "ZINTERNAL", DNSSkipRecord: true}, }, nil, nil, nil) if len(policies) != 2 { t.Fatalf("policies = %d, want one per Route53 hosted zone", len(policies)) } want := map[string]string{"ZPUBLIC": "public.example.com", "ZINTERNAL": "internal.example.net"} for _, policy := range policies { issuers := policy["issuers"].([]any) issuer := issuers[0].(map[string]any) challenge := issuer["challenges"].(map[string]any)["dns"].(map[string]any) provider := challenge["provider"].(map[string]any) zone, _ := provider["hosted_zone_id"].(string) subjects := policy["subjects"].([]any) if len(subjects) != 1 || subjects[0] != want[zone] { t.Fatalf("zone %q subjects = %#v, want %q", zone, subjects, want[zone]) } delete(want, zone) } if len(want) != 0 { t.Fatalf("missing Route53 zone policies: %#v", want) } } func TestBuildInternalTLSAutomationPolicies(t *testing.T) { proxies := []models.ProxyHost{ {Domains: "nas.home.lan", Enabled: true, SSLEnabled: true, InternalTLS: true}, {Domains: "printer.home.lan", Enabled: true, SSLEnabled: true, InternalTLS: true}, // Excluded: opted in but pinned to a custom certificate. {Domains: "custom.home.lan", Enabled: true, SSLEnabled: true, InternalTLS: true, CertificateID: 7}, // Excluded: not opted in (default Auto ACME). {Domains: "public.example.com", Enabled: true, SSLEnabled: true}, // Excluded: disabled and SSL-off hosts never issue. {Domains: "off.home.lan", Enabled: false, SSLEnabled: true, InternalTLS: true}, {Domains: "plain.home.lan", Enabled: true, SSLEnabled: false, InternalTLS: true}, } policies := buildInternalTLSAutomationPolicies(proxies) if len(policies) != 1 { t.Fatalf("policies = %d, want a single internal-issuer policy", len(policies)) } issuers := policies[0]["issuers"].([]any) if module := issuers[0].(map[string]any)["module"]; module != "internal" { t.Fatalf("issuer module = %v, want \"internal\"", module) } got := map[string]bool{} for _, s := range policies[0]["subjects"].([]any) { got[s.(string)] = true } want := map[string]bool{"nas.home.lan": true, "printer.home.lan": true} if len(got) != len(want) { t.Fatalf("subjects = %#v, want %#v", got, want) } for name := range want { if !got[name] { t.Fatalf("missing internal-CA subject %q in %#v", name, got) } } for _, unexpected := range []string{"custom.home.lan", "public.example.com", "off.home.lan", "plain.home.lan"} { if got[unexpected] { t.Fatalf("subject %q should not be issued by the internal CA", unexpected) } } } // An internal-CA host must never be folded into automatic_https.skip_certificates // by wildcard-managed-certificate coverage: it gets its own cert from the // internal issuer, so skipping it would leave it with no certificate at all. func TestBuildSkipCertificatesExcludesInternalTLSHosts(t *testing.T) { proxies := []models.ProxyHost{ {Domains: "nas.home.lan", Enabled: true, SSLEnabled: true, InternalTLS: true}, } certs := []models.Certificate{ {Source: models.CertSourceManaged, Domains: "*.home.lan"}, } skip := buildSkipCertificates(proxies, nil, nil, certs) for _, s := range skip { if s == "nas.home.lan" { t.Fatalf("internal-CA host must not be in skip_certificates; got %#v", skip) } } } // buildDNSAutomationPolicies must not emit a DNS-01 policy for a host that // opted into internal-CA issuance, even if it still carries a DNS provider. func TestDNSAutomationPoliciesSkipInternalTLSHosts(t *testing.T) { conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = conn.Close() }) for key, value := range map[string]string{ settingRoute53AccessKeyID: "AKIAEXAMPLE", settingRoute53SecretAccessKey: "secret", } { if err := models.SetSetting(conn, key, value); err != nil { t.Fatal(err) } } s := &Server{DB: conn} policies := s.buildDNSAutomationPolicies([]models.ProxyHost{ {Domains: "nas.home.lan", Enabled: true, SSLEnabled: true, InternalTLS: true, DNSProvider: dns.Route53, DNSZoneID: "ZHOME"}, }, nil, nil, nil) if len(policies) != 0 { t.Fatalf("policies = %d, want 0 — internal-CA hosts must not get a DNS-01 policy", len(policies)) } } func TestMergeAutomationPoliciesReplacesDesiredSubjectsOnly(t *testing.T) { existing := []any{ map[string]any{"subjects": []any{"replace.example.com", "keep.example.com"}, "issuer": "old"}, map[string]any{"issuer": "catch-all"}, } incoming := []map[string]any{{"subjects": []any{"replace.example.com"}, "issuer": "new"}} got := mergeAutomationPolicies(existing, incoming) if len(got) != 3 { t.Fatalf("policies = %#v, want incoming + trimmed existing + catch-all", got) } if got[0].(map[string]any)["issuer"] != "new" { t.Fatalf("first policy = %#v, want incoming authoritative policy", got[0]) } if !reflect.DeepEqual(got[1].(map[string]any)["subjects"], []any{"keep.example.com"}) { t.Fatalf("preserved subjects = %#v", got[1]) } if got[2].(map[string]any)["issuer"] != "catch-all" { t.Fatalf("catch-all policy was not preserved: %#v", got[2]) } } func TestManagedCertificateRoutesTriggerAutomaticHTTPS(t *testing.T) { certs := []models.Certificate{ {ID: 1, Source: models.CertSourcePEM, Domains: "*.ignored.example.com"}, {ID: 2, Source: models.CertSourceManaged, Domains: "*.example.com, *.sub.example.com"}, } routes := buildManagedCertificateRoutes(certs, nil) if len(routes) != 1 { t.Fatalf("routes = %d, want 1 managed-certificate route", len(routes)) } route := routes[0].(map[string]any) match := route["match"].([]any)[0].(map[string]any) wantHosts := []any{"*.example.com", "*.sub.example.com"} if !reflect.DeepEqual(match["host"], wantHosts) { t.Fatalf("hosts = %#v, want %#v", match["host"], wantHosts) } handler := route["handle"].([]any)[0].(map[string]any) if handler["status_code"] != 404 || route["terminal"] != true { t.Fatalf("managed fallback = %#v, want terminal 404", route) } } func TestManagedCertificatePersistsDNSSelectionAndIsNotCustomOption(t *testing.T) { conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = conn.Close() }) id, err := models.CreateCertificate(conn, 1, 0, &models.Certificate{ Name: "wildcard", Domains: "*.example.com", Source: models.CertSourceManaged, DNSProvider: dns.Cloudflare, DNSProfileID: "profile-1", }) if err != nil { t.Fatal(err) } got, err := models.GetCertificate(conn, id) if err != nil { t.Fatal(err) } if got.Source != models.CertSourceManaged || got.DNSProvider != dns.Cloudflare || got.DNSProfileID != "profile-1" { t.Fatalf("managed certificate round trip = %#v", got) } options, err := models.ListCertificateOptionsForUser(conn, 1, 0, true, nil) if err != nil { t.Fatal(err) } if len(options) != 0 { t.Fatalf("custom certificate options = %#v, managed certificate must not be assignable as uploaded PEM", options) } } func TestManagedCertificateCoverageUsesSingleLabelWildcards(t *testing.T) { tests := []struct { cert string host string want bool }{ {"*.example.com", "app.example.com", true}, {"*.example.com", "app.example.com:8000", true}, {"*.example.com", "APP.EXAMPLE.COM.", true}, {"*.example.com", "deep.app.example.com", false}, {"*.example.com", "example.com", false}, {"*.sub.example.com", "app.sub.example.com", true}, {"app.example.com", "app.example.com", true}, {"*.example.com", "*.example.com", true}, } for _, tt := range tests { if got := managedCertificateCovers(tt.cert, tt.host); got != tt.want { t.Errorf("managedCertificateCovers(%q, %q) = %v, want %v", tt.cert, tt.host, got, tt.want) } } } func TestManagedWildcardForHost(t *testing.T) { certs := []models.Certificate{ {ID: 1, Name: "Direct managed", Domains: "app.example.com", Source: models.CertSourceManaged}, {ID: 2, Name: "Example wildcard", Domains: "*.example.com", Source: models.CertSourceManaged}, {ID: 3, Name: "Uploaded wildcard", Domains: "*.example.net", Source: models.CertSourcePEM}, } if got := managedWildcardForHost(certs, "app.example.com"); got == nil || got.Name != "Example wildcard" { t.Fatalf("managedWildcardForHost() = %#v, want Example wildcard", got) } if got := managedWildcardForHost(certs, "app.example.com:8000"); got == nil || got.Name != "Example wildcard" { t.Fatalf("managedWildcardForHost() with port = %#v, want Example wildcard", got) } if got := managedWildcardForHost(certs, "deep.app.example.com"); got != nil { t.Fatalf("managedWildcardForHost() = %#v, want nil for multi-label host", got) } if got := managedWildcardForHost(certs, "app.example.net"); got != nil { t.Fatalf("managedWildcardForHost() = %#v, want nil for uploaded PEM wildcard", got) } if got := managedWildcardForHost(certs, "uncovered.example.org"); got != nil { t.Fatalf("managedWildcardForHost() = %#v, want nil for uncovered host", got) } } func TestManagedWildcardSkipsCoveredExactCertificateIssuance(t *testing.T) { proxies := []models.ProxyHost{ {Domains: "project.sub.example.com", Enabled: true, SSLEnabled: true}, {Domains: "deep.project.sub.example.com", Enabled: true, SSLEnabled: true}, {Domains: "sub.example.com", Enabled: true, SSLEnabled: true}, } certs := []models.Certificate{ {Source: models.CertSourceManaged, Domains: "*.sub.example.com"}, } got := buildSkipCertificates(proxies, nil, nil, certs) if !reflect.DeepEqual(got, []any{"project.sub.example.com"}) { t.Fatalf("skip certificates = %#v, want only wildcard-covered exact hostname", got) } } func TestManagedWildcardSubjectItselfIsNeverSkipped(t *testing.T) { proxies := []models.ProxyHost{ {Domains: "*.sub.example.com", Enabled: true, SSLEnabled: true}, } certs := []models.Certificate{ {Source: models.CertSourceManaged, Domains: "*.sub.example.com"}, } if got := buildSkipCertificates(proxies, nil, nil, certs); len(got) != 0 { t.Fatalf("skip certificates = %#v, wildcard subject must remain eligible for issuance", got) } } func TestPortQualifiedHostReusesManagedWildcard(t *testing.T) { proxies := []models.ProxyHost{ {Domains: "project.sub.example.com:8000", Enabled: true, SSLEnabled: true}, } certs := []models.Certificate{ {Source: models.CertSourceManaged, Domains: "*.sub.example.com"}, } if got := buildSkipCertificates(proxies, nil, nil, certs); !reflect.DeepEqual(got, []any{"project.sub.example.com"}) { t.Fatalf("skip certificates = %#v, want port-free wildcard-covered hostname", got) } route := caddy.BuildProxyRoute(models.ProxyHost{ Domains: "project.sub.example.com:8000", ForwardHost: "app", ForwardPort: 8080, ForwardScheme: "http", }, nil) match := route["match"].([]any)[0].(map[string]any) if got := match["host"]; !reflect.DeepEqual(got, []any{"project.sub.example.com"}) { t.Fatalf("route host matcher = %#v, want port-free hostname", got) } } func TestManagedCertificateProbeNameUsesSyntheticWildcardChild(t *testing.T) { cert := models.Certificate{Domains: "example.com, *.sub.example.com"} if got := managedCertificateProbeName(cert); got != "caddyui-probe.sub.example.com" { t.Fatalf("probe name = %q", got) } } func TestEnsureManagedCertificateOnServerDeduplicatesEquivalentSubjects(t *testing.T) { conn, err := appdb.Open(filepath.Join(t.TempDir(), "caddyui.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = conn.Close() }) sourceServerID, err := models.CreateCaddyServer(conn, &models.CaddyServer{ Name: "source", AdminURL: "http://source:2019", Type: models.CaddyServerTypeManaged, }) if err != nil { t.Fatal(err) } targetID, err := models.CreateCaddyServer(conn, &models.CaddyServer{ Name: "target", AdminURL: "http://target:2019", Type: models.CaddyServerTypeManaged, }) if err != nil { t.Fatal(err) } s := &Server{DB: conn} source := models.Certificate{ Name: "wildcard", Domains: "*.example.com, example.com", Source: models.CertSourceManaged, DNSProvider: dns.Cloudflare, } source.ID, err = models.CreateCertificate(conn, sourceServerID, 0, &source) if err != nil { t.Fatal(err) } created, err := s.ensureCertificateOnServer("test", sourceServerID, targetID, source, 0) if err != nil || !created { t.Fatalf("first ensure = created %v, err %v; want created", created, err) } source.Domains = "example.com, *.example.com" created, err = s.ensureCertificateOnServer("test", sourceServerID, targetID, source, 0) if err != nil || created { t.Fatalf("second ensure = created %v, err %v; want existing equivalent", created, err) } certs, err := models.ListCertificates(conn, targetID) if err != nil { t.Fatal(err) } if len(certs) != 1 { t.Fatalf("target certificates = %d, want 1", len(certs)) } } func TestCaddyDNSProviderConfigRejectsIncompleteCredentials(t *testing.T) { if got := caddyDNSProviderConfig(dns.Porkbun, map[string]string{"pb_api_key": "key"}, ""); got != nil { t.Fatalf("incomplete credentials returned config %#v", got) } if got := caddyDNSProviderConfig(dns.Route53, map[string]string{"route53_access_key_id": "AKIAEXAMPLE"}, ""); got != nil { t.Fatalf("incomplete Route 53 credentials returned config %#v", got) } } func TestPushAutomationPoliciesCreatesMissingTLSApp(t *testing.T) { var postedPath string var posted map[string]any admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/config/": _ = json.NewEncoder(w).Encode(map[string]any{ "apps": map[string]any{"http": map[string]any{}}, }) case r.Method == http.MethodPost: postedPath = r.URL.Path if err := json.NewDecoder(r.Body).Decode(&posted); err != nil { t.Errorf("decode POST: %v", err) } w.WriteHeader(http.StatusOK) default: http.Error(w, "unexpected request", http.StatusBadRequest) } })) defer admin.Close() incoming := []map[string]any{{ "subjects": []any{"example.com"}, "issuers": []any{map[string]any{"module": "acme"}}, }} if err := pushAutomationPoliciesVia(caddy.New(admin.URL, "", ""), incoming); err != nil { t.Fatal(err) } if postedPath != "/config/apps/tls" { t.Fatalf("POST path = %q, want /config/apps/tls", postedPath) } automation, _ := posted["automation"].(map[string]any) policies, _ := automation["policies"].([]any) if len(policies) != 1 { t.Fatalf("posted policies = %#v, want one policy", policies) } } func TestPushAutomationPoliciesPreservesExistingTLSAndUsesAutomationPath(t *testing.T) { var postedPath string var posted map[string]any admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodGet && r.URL.Path == "/config/": _ = json.NewEncoder(w).Encode(map[string]any{ "apps": map[string]any{ "http": map[string]any{}, "tls": map[string]any{ "automation": map[string]any{"on_demand": map[string]any{}}, }, }, }) case r.Method == http.MethodPost: postedPath = r.URL.Path if err := json.NewDecoder(r.Body).Decode(&posted); err != nil { t.Errorf("decode POST: %v", err) } w.WriteHeader(http.StatusOK) default: http.Error(w, "unexpected request", http.StatusBadRequest) } })) defer admin.Close() incoming := []map[string]any{{"subjects": []any{"example.com"}}} if err := pushAutomationPoliciesVia(caddy.New(admin.URL, "", ""), incoming); err != nil { t.Fatal(err) } if postedPath != "/config/apps/tls/automation" { t.Fatalf("POST path = %q, want /config/apps/tls/automation", postedPath) } if _, ok := posted["on_demand"]; !ok { t.Fatalf("existing automation fields were not preserved: %#v", posted) } }