diff --git a/docs/cli/datumctl-alb.md b/docs/cli/datumctl-alb.md index 6ca3aa69..175f337f 100644 --- a/docs/cli/datumctl-alb.md +++ b/docs/cli/datumctl-alb.md @@ -137,6 +137,24 @@ datumctl alb hostname remove my-app app.example.com The generated hostname is assigned by the platform and shown by `describe`. Custom hostnames must be unique on the platform, and ownership of their domain is verified separately — this plugin does not create or read the domain, so use `datumctl get domains` to see verification state. `hostname remove` does not ask for confirmation. `list` shows the generated hostname and a `CUSTOM` summary (first attached name, `+N` when there are more); `describe` prints each custom hostname with available / DNS / cert status. +### Wildcards + +```sh +datumctl alb hostname add my-app '*.s3.example.com' +datumctl alb describe my-app +``` + +Quote the wildcard so the shell leaves the `*` alone. A wildcard serves every name beneath its base and reserves them for your project; its certificate covers names one label down. The platform admits it only where wildcards are enabled and only once `s3.example.com`, or a parent, is verified by its DNS TXT record — a domain verified over HTTP or through a Datum DNS zone does not count. `hostname add` does not wait: a refused wildcard shows its reason under the hostname in `describe`. + +`describe` ends with the DNS records still to publish, read from the load balancer's status: the routing CNAME, the `_acme-challenge` certificate CNAME, and the domain's ownership TXT, each once. Records the platform publishes in a Datum DNS zone are listed apart, and a domain whose registry does not yet delegate to Datum DNS gets a `DNS not delegated` hint that points at the certificate record. + +``` +DNS records to publish: + NAME TYPE CONTENT PURPOSE + *.s3.example.com CNAME ruth-fourth-hrkgk.datumproxy.net Routing + _acme-challenge.s3.example.com CNAME k3f9q2x7.acme-dns.example.net Certificate +``` + ## Access logs ```sh diff --git a/internal/cmd/alb/describe.go b/internal/cmd/alb/describe.go index ba241520..5e3e85ee 100644 --- a/internal/cmd/alb/describe.go +++ b/internal/cmd/alb/describe.go @@ -81,10 +81,14 @@ func runDescribe(cmd *cobra.Command, args []string) error { util.ConditionStatus(hs.Conditions, networkingv1alpha.HostnameConditionDNSRecordProgrammed), util.ConditionStatus(hs.Conditions, networkingv1alpha.HostnameConditionCertificateReady), ) + if problem := hostnameProblem(hs); problem != "" { + _, _ = fmt.Fprintf(out, " %s\n", problem) + } continue } _, _ = fmt.Fprintf(out, " %s\n", name) } + writePendingRecords(out, collectPendingRecords(proxy, hostnames)) } if connector := spec.ConnectorName(proxy); connector != "" { _, _ = fmt.Fprintf(out, "Connector: %s\n", connector) diff --git a/internal/cmd/alb/dnsrecords.go b/internal/cmd/alb/dnsrecords.go new file mode 100644 index 00000000..6b9b68d7 --- /dev/null +++ b/internal/cmd/alb/dnsrecords.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package alb + +import ( + "fmt" + "io" + "strings" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + "go.datum.net/network-services-operator/internal/cmd/alb/util" +) + +type pendingRecords struct { + toPublish []networkingv1alpha.HostnameDNSRecord + awaitingDatum []networkingv1alpha.HostnameDNSRecord + undelegated []string +} + +// collectPendingRecords reads the records each custom hostname still needs +// from its status, each record once however many hostnames share it. +func collectPendingRecords(proxy *networkingv1alpha.HTTPProxy, hostnames []string) pendingRecords { + var pending pendingRecords + statusByName := map[string]networkingv1alpha.HostnameStatus{} + for _, hs := range proxy.Status.HostnameStatuses { + statusByName[hs.Hostname] = hs + } + + seen := map[string]bool{} + for _, name := range hostnames { + hs, ok := statusByName[name] + if !ok { + continue + } + if c := apimeta.FindStatusCondition(hs.Conditions, networkingv1alpha.HostnameConditionDNSRecordProgrammed); c != nil && + c.Status == metav1.ConditionFalse && c.Reason == networkingv1alpha.DNSRecordReasonDNSAuthorityMissing { + pending.undelegated = append(pending.undelegated, name) + } + for _, record := range hs.DNSRecords { + if record.State != networkingv1alpha.HostnameDNSRecordMissing { + continue + } + key := record.Name + "|" + record.Type + "|" + record.Content + if seen[key] { + continue + } + seen[key] = true + if record.ManagedBy == networkingv1alpha.HostnameDNSRecordManagedByPlatform { + pending.awaitingDatum = append(pending.awaitingDatum, record) + } else { + pending.toPublish = append(pending.toPublish, record) + } + } + } + return pending +} + +func writePendingRecords(out io.Writer, pending pendingRecords) { + if len(pending.toPublish) > 0 { + _, _ = fmt.Fprintln(out, "DNS records to publish:") + tw := util.NewTabWriter(out) + _, _ = fmt.Fprintln(tw, " NAME\tTYPE\tCONTENT\tPURPOSE") + for _, r := range pending.toPublish { + _, _ = fmt.Fprintf(tw, " %s\t%s\t%s\t%s\n", r.Name, r.Type, r.Content, r.Purpose) + } + _ = tw.Flush() + } + + for _, r := range pending.awaitingDatum { + _, _ = fmt.Fprintf(out, "Waiting on Datum DNS: %s %s %s (%s)\n", r.Name, r.Type, r.Content, r.Purpose) + } + + if len(pending.undelegated) == 0 { + return + } + var certificate *networkingv1alpha.HostnameDNSRecord + for i := range pending.toPublish { + if pending.toPublish[i].Purpose == networkingv1alpha.HostnameDNSRecordPurposeCertificate { + certificate = &pending.toPublish[i] + break + } + } + names := strings.Join(pending.undelegated, ", ") + if certificate != nil { + _, _ = fmt.Fprintf(out, "DNS not delegated: Datum DNS does not serve %s yet. Publish the certificate record %s at the DNS provider that does serve it, so the certificate issues before traffic moves.\n", + names, certificate.Name) + return + } + _, _ = fmt.Fprintf(out, "DNS not delegated: Datum DNS does not serve %s yet. Point the domain's NS records at its Datum DNS zone (see `datumctl dns`), or publish the routing record at the DNS provider that serves it.\n", + names) +} + +// hostnameProblem returns why a custom hostname is held back, when its +// ownership or claim was refused. +func hostnameProblem(hs networkingv1alpha.HostnameStatus) string { + for _, condType := range []string{networkingv1alpha.HostnameConditionVerified, networkingv1alpha.HostnameConditionAvailable} { + if c := apimeta.FindStatusCondition(hs.Conditions, condType); c != nil && c.Status == metav1.ConditionFalse && c.Message != "" { + return c.Message + } + } + return "" +} diff --git a/internal/cmd/alb/dnsrecords_test.go b/internal/cmd/alb/dnsrecords_test.go new file mode 100644 index 00000000..41e5fe68 --- /dev/null +++ b/internal/cmd/alb/dnsrecords_test.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package alb + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + "go.datum.net/network-services-operator/internal/cmd/alb/spec" +) + +func record(name, rrType, content string, purpose networkingv1alpha.HostnameDNSRecordPurpose, by networkingv1alpha.HostnameDNSRecordManager, state networkingv1alpha.HostnameDNSRecordState) networkingv1alpha.HostnameDNSRecord { + return networkingv1alpha.HostnameDNSRecord{Name: name, Type: rrType, Content: content, Purpose: purpose, ManagedBy: by, State: state} +} + +func wildcardProxy() *networkingv1alpha.HTTPProxy { + ownership := record("datum-custom-hostname.example.com", "TXT", "4f1c-token", networkingv1alpha.HostnameDNSRecordPurposeOwnership, networkingv1alpha.HostnameDNSRecordManagedByUser, networkingv1alpha.HostnameDNSRecordMissing) + return &networkingv1alpha.HTTPProxy{ + Spec: networkingv1alpha.HTTPProxySpec{Hostnames: []gatewayv1.Hostname{"*.s3.example.com", "www.example.com"}}, + Status: networkingv1alpha.HTTPProxyStatus{HostnameStatuses: []networkingv1alpha.HostnameStatus{ + { + Hostname: "*.s3.example.com", + Conditions: []metav1.Condition{{ + Type: networkingv1alpha.HostnameConditionDNSRecordProgrammed, Status: metav1.ConditionFalse, Reason: networkingv1alpha.DNSRecordReasonDNSAuthorityMissing, + }}, + DNSRecords: []networkingv1alpha.HostnameDNSRecord{ + record("*.s3.example.com", "CNAME", "ruth-fourth-hrkgk.datumproxy.net", networkingv1alpha.HostnameDNSRecordPurposeRouting, networkingv1alpha.HostnameDNSRecordManagedByUser, networkingv1alpha.HostnameDNSRecordMissing), + record("_acme-challenge.s3.example.com", "CNAME", "k3f9q2x7.acme-dns.example.net", networkingv1alpha.HostnameDNSRecordPurposeCertificate, networkingv1alpha.HostnameDNSRecordManagedByUser, networkingv1alpha.HostnameDNSRecordMissing), + ownership, + }, + }, + { + Hostname: "www.example.com", + DNSRecords: []networkingv1alpha.HostnameDNSRecord{ + record("www.example.com", "CNAME", "ruth-fourth-hrkgk.datumproxy.net", networkingv1alpha.HostnameDNSRecordPurposeRouting, networkingv1alpha.HostnameDNSRecordManagedByUser, networkingv1alpha.HostnameDNSRecordPresent), + ownership, + }, + }, + }}, + } +} + +func TestPendingRecordsListsWhatIsLeftOnce(t *testing.T) { + proxy := wildcardProxy() + var out bytes.Buffer + writePendingRecords(&out, collectPendingRecords(proxy, spec.Hostnames(proxy))) + got := out.String() + + assert.Contains(t, got, "DNS records to publish:") + assert.Contains(t, got, "_acme-challenge.s3.example.com") + assert.Contains(t, got, "k3f9q2x7.acme-dns.example.net") + assert.Contains(t, got, "Certificate") + assert.Equal(t, 1, bytes.Count(out.Bytes(), []byte("datum-custom-hostname.example.com")), "a record shared by two hostnames is listed once") + assert.NotContains(t, got, "www.example.com ", "a present record is not listed") +} + +func TestDNSNotDelegatedPointsAtTheCertificateRecord(t *testing.T) { + proxy := wildcardProxy() + var out bytes.Buffer + writePendingRecords(&out, collectPendingRecords(proxy, spec.Hostnames(proxy))) + + assert.Contains(t, out.String(), "DNS not delegated: Datum DNS does not serve *.s3.example.com yet. Publish the certificate record _acme-challenge.s3.example.com") +} + +func TestDNSNotDelegatedWithoutACertificateRecord(t *testing.T) { + proxy := wildcardProxy() + proxy.Status.HostnameStatuses[0].DNSRecords = proxy.Status.HostnameStatuses[0].DNSRecords[:1] + var out bytes.Buffer + writePendingRecords(&out, collectPendingRecords(proxy, spec.Hostnames(proxy))) + + assert.Contains(t, out.String(), "see `datumctl dns`") +} + +func TestPlatformRecordsAreNotTheUsersToPublish(t *testing.T) { + proxy := &networkingv1alpha.HTTPProxy{ + Spec: networkingv1alpha.HTTPProxySpec{Hostnames: []gatewayv1.Hostname{"www.example.com"}}, + Status: networkingv1alpha.HTTPProxyStatus{HostnameStatuses: []networkingv1alpha.HostnameStatus{{ + Hostname: "www.example.com", + DNSRecords: []networkingv1alpha.HostnameDNSRecord{ + record("www.example.com", "CNAME", "ruth-fourth-hrkgk.datumproxy.net", networkingv1alpha.HostnameDNSRecordPurposeRouting, networkingv1alpha.HostnameDNSRecordManagedByPlatform, networkingv1alpha.HostnameDNSRecordMissing), + }, + }}}, + } + var out bytes.Buffer + writePendingRecords(&out, collectPendingRecords(proxy, spec.Hostnames(proxy))) + + assert.NotContains(t, out.String(), "DNS records to publish") + assert.Contains(t, out.String(), "Waiting on Datum DNS: www.example.com CNAME") +} + +func TestHostnameProblemExplainsARefusedWildcard(t *testing.T) { + hs := networkingv1alpha.HostnameStatus{Conditions: []metav1.Condition{{ + Type: networkingv1alpha.HostnameConditionVerified, Status: metav1.ConditionFalse, + Reason: networkingv1alpha.HostnameVerifiedReasonDNSVerificationRequired, Message: `The wildcard "*.s3.example.com" needs DNS proof`, + }}} + assert.Equal(t, `The wildcard "*.s3.example.com" needs DNS proof`, hostnameProblem(hs)) + assert.Empty(t, hostnameProblem(networkingv1alpha.HostnameStatus{})) +} + +func TestHostnameAddAcceptsWildcards(t *testing.T) { + updated, err := spec.AddHostname(&networkingv1alpha.HTTPProxy{ObjectMeta: metav1.ObjectMeta{Name: "s3"}}, "*.S3.example.com") + if assert.NoError(t, err) { + assert.Equal(t, []gatewayv1.Hostname{"*.s3.example.com"}, updated.Spec.Hostnames) + } + + _, err = spec.AddHostname(&networkingv1alpha.HTTPProxy{ObjectMeta: metav1.ObjectMeta{Name: "s3"}}, "*.*.example.com") + assert.Error(t, err) +} diff --git a/internal/cmd/alb/spec/proxy.go b/internal/cmd/alb/spec/proxy.go index e43b6b04..c3c2c8a9 100644 --- a/internal/cmd/alb/spec/proxy.go +++ b/internal/cmd/alb/spec/proxy.go @@ -224,7 +224,9 @@ func toHostnames(hostnames []string) []gatewayv1.Hostname { } func validateProxy(proxy *networkingv1alpha.HTTPProxy) error { - errs := validation.ValidateHTTPProxy(proxy, validation.HTTPProxyValidationOptions{}) + errs := validation.ValidateHTTPProxy(proxy, validation.HTTPProxyValidationOptions{ + Hostnames: validation.HostnameOptions{AllowWildcards: true}, + }) if len(errs) == 0 { return nil }