Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ ARG TARGETOS
ARG TARGETARCH
ARG TARGETPLATFORM
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /manager ./app/operator/cmd/manager \
&& go run ./scripts/verify-controller-architecture /manager "${TARGETPLATFORM}"
&& go run ./scripts/verify-controller-architecture /manager "${TARGETPLATFORM}" \
&& CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -trimpath -ldflags="-s -w" -o /hosted-server ./app/hosted-server/cmd/server \
&& go run ./scripts/verify-controller-architecture /hosted-server "${TARGETPLATFORM}"

FROM gcr.io/distroless/static:nonroot

WORKDIR /
COPY --from=builder /manager /manager
COPY --from=builder /hosted-server /hosted-server

USER 65532:65532
ENTRYPOINT ["/manager"]
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ dns-api is currently focused on:

- Amazon Route 53 public hosted zones, and adoption of existing private hosted zones
- Cloudflare full zones
- External verification of customer-managed DNS (opt-in, credentialless)
- [Hosted authoritative DNS](./docs/design/hosted-provider.md) served from Kubernetes snapshots (opt-in, credentialless)
- `A`, `AAAA`, `TXT`, `CNAME`, `MX`, `CAA`, and delegated `NS` records
- Route 53 `ALIAS` records
- A Headlamp Desktop plugin for browsing and editing dns-api resources
Expand Down Expand Up @@ -47,6 +49,16 @@ ghcr.io/appthrust/dns-api:<version>

For local development or source installs, use the chart in [deploy/charts/dns-api](./deploy/charts/dns-api).

The hosted provider is opt-in with `providers.hosted.enabled=true` and requires
webhook and cert-manager support for endpoint conversion. It runs the manager's
hosted controllers and a separate `/hosted-server` process from the same image.
The server Service exposes UDP/TCP port 53, forwarding to non-root port 5353.
Set `providers.hosted.server.service.type=LoadBalancer` and the Service VIP/glue
configuration when publishing an authority outside the cluster. Hosted
ZoneClasses omit `identityRef`; configure `nameServers` and delegate the zone
from its parent. See [Hosted Authoritative Runtime](./docs/design/provider-extension.md#hosted-authoritative-runtime)
for runtime settings, namespace scope, and endpoint TTL configuration.

### Customer-managed DNS (external provider)

The external provider is opt-in. It verifies customer-managed `A`, `AAAA`, and
Expand Down
29 changes: 29 additions & 0 deletions app/hosted-server/cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package main

import (
"flag"
"os"

"github.com/appthrust/dns-api/internal/go/providers/hosted/server"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
)

func main() {
options := server.Options{}
flag.StringVar(&options.ListenAddr, "listen-address", ":5353", "Address for authoritative DNS over UDP and TCP.")
flag.StringVar(&options.WatchNamespace, "watch-namespace", "", "Namespace containing HostedZone snapshots; empty watches all namespaces.")
flag.IntVar(&options.MaxZones, "max-zones", 4096, "Maximum number of served zones.")
flag.IntVar(&options.UDPRateLimitPerSource, "udp-rate-limit", 1000, "Maximum UDP queries per second per source address.")
flag.StringVar(&options.MetricsAddr, "metrics-bind-address", ":8080", "Metrics listener address; 0 disables the listener.")
flag.StringVar(&options.HealthAddr, "health-probe-bind-address", ":8081", "Health listener address; 0 disables the listener.")
zapOptions := zap.Options{}
zapOptions.BindFlags(flag.CommandLine)
flag.Parse()
ctrl.SetLogger(zap.New(zap.UseFlagOptions(&zapOptions)))

if err := server.Run(ctrl.SetupSignalHandler(), options); err != nil {
ctrl.Log.Error(err, "problem running hosted DNS server")
os.Exit(1)
}
}
84 changes: 84 additions & 0 deletions app/hosted-server/cmd/server/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package main

import (
"encoding/json"
"flag"
"os"
"os/exec"
"testing"
)

// Exercise the real entrypoint's flags; help exits before the Kubernetes client
// and DNS listeners are constructed.
func TestServerFlagsProcess(t *testing.T) {
if os.Getenv("DNS_API_TEST_SERVER_FLAGS") != "1" {
return
}
for i, arg := range os.Args {
if arg == "--" {
os.Args = append([]string{os.Args[0]}, os.Args[i+1:]...)
break
}
}
flag.CommandLine = flag.NewFlagSet("hosted-server", flag.ExitOnError)
flag.CommandLine.Usage = func() {
values := map[string]string{}
flag.CommandLine.VisitAll(func(f *flag.Flag) { values[f.Name] = f.Value.String() })
if err := json.NewEncoder(os.Stdout).Encode(values); err != nil {
os.Exit(1)
}
}
main()
}

func TestServerFlags(t *testing.T) {
for _, test := range []struct {
name string
args []string
want map[string]string
}{
{
name: "defaults",
want: map[string]string{
"listen-address": ":5353",
"watch-namespace": "",
"max-zones": "4096",
"udp-rate-limit": "1000",
"metrics-bind-address": ":8080",
"health-probe-bind-address": ":8081",
},
},
{
name: "overrides",
args: []string{"--listen-address=:1053", "--watch-namespace=tenant-dns", "--max-zones=16", "--udp-rate-limit=25", "--metrics-bind-address=0", "--health-probe-bind-address=0"},
want: map[string]string{
"listen-address": ":1053",
"watch-namespace": "tenant-dns",
"max-zones": "16",
"udp-rate-limit": "25",
"metrics-bind-address": "0",
"health-probe-bind-address": "0",
},
},
} {
t.Run(test.name, func(t *testing.T) {
args := append([]string{"-test.run=^TestServerFlagsProcess$", "--"}, test.args...)
args = append(args, "--help")
cmd := exec.Command(os.Args[0], args...)
cmd.Env = append(os.Environ(), "DNS_API_TEST_SERVER_FLAGS=1")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("server flag parsing failed: %v\n%s", err, output)
}
var actual map[string]string
if err := json.Unmarshal(output, &actual); err != nil {
t.Fatalf("decode server flags: %v\n%s", err, output)
}
for name, want := range test.want {
if actual[name] != want {
t.Errorf("--%s = %q, want %q", name, actual[name], want)
}
}
})
}
}
65 changes: 64 additions & 1 deletion app/operator/cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
gatewayendpointcontroller "github.com/appthrust/dns-api/internal/go/apps/gatewayendpoint/controllers/gateway"
serviceendpointcontroller "github.com/appthrust/dns-api/internal/go/apps/serviceendpoint/controllers/service"
zoneunitcontroller "github.com/appthrust/dns-api/internal/go/core/controllers/zoneunit"
externalprobe "github.com/appthrust/dns-api/internal/go/core/dnsprobe"
corewebhook "github.com/appthrust/dns-api/internal/go/core/webhook"
cloudflareidentity "github.com/appthrust/dns-api/internal/go/providers/cloudflare/controllers/identity"
cloudflarezoneclass "github.com/appthrust/dns-api/internal/go/providers/cloudflare/controllers/zoneclass"
Expand All @@ -17,7 +18,9 @@ import (
externalzoneclass "github.com/appthrust/dns-api/internal/go/providers/external/controllers/zoneclass"
externalzoneunit "github.com/appthrust/dns-api/internal/go/providers/external/controllers/zoneunit"
externalconversion "github.com/appthrust/dns-api/internal/go/providers/external/conversion"
externalprobe "github.com/appthrust/dns-api/internal/go/providers/external/probe"
hostedzoneclass "github.com/appthrust/dns-api/internal/go/providers/hosted/controllers/zoneclass"
hostedzoneunit "github.com/appthrust/dns-api/internal/go/providers/hosted/controllers/zoneunit"
hostedconversion "github.com/appthrust/dns-api/internal/go/providers/hosted/conversion"
route53identity "github.com/appthrust/dns-api/internal/go/providers/route53/controllers/identity"
route53zoneclass "github.com/appthrust/dns-api/internal/go/providers/route53/controllers/zoneclass"
route53zoneunit "github.com/appthrust/dns-api/internal/go/providers/route53/controllers/zoneunit"
Expand All @@ -28,6 +31,7 @@ import (
endpointconversionv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/conversion/v1alpha1"
endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1"
externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1"
hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1"
route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
Expand Down Expand Up @@ -80,6 +84,7 @@ func init() {
utilruntime.Must(endpointconversionv1alpha1.AddToScheme(scheme))
utilruntime.Must(externalv1alpha1.AddToScheme(scheme))
utilruntime.Must(gatewayv1.Install(scheme))
utilruntime.Must(hostedv1alpha1.AddToScheme(scheme))
utilruntime.Must(route53v1alpha1.AddToScheme(scheme))
}

Expand All @@ -92,6 +97,7 @@ func main() {
route53ControllerEnabled := envBoolOrDefault("ROUTE53_CONTROLLER_ENABLED", true)
cloudflareControllerEnabled := envBoolOrDefault("CLOUDFLARE_CONTROLLER_ENABLED", true)
externalControllerEnabled := envBoolOrDefault("EXTERNAL_CONTROLLER_ENABLED", false)
hostedControllerEnabled := envBoolOrDefault("HOSTED_CONTROLLER_ENABLED", false)
route53EndpointConversionEnabled := envBoolOrDefault("ROUTE53_ENDPOINT_CONVERSION_ENABLED", true)
endpointRecordSetNamespace := envOrDefault("ENDPOINT_RECORDSET_NAMESPACE", "")
route53ControllerName := envOrDefault("ROUTE53_CONTROLLER_NAME", route53zoneunit.DefaultControllerName)
Expand All @@ -103,6 +109,10 @@ func main() {
externalControllerName := envOrDefault("EXTERNAL_CONTROLLER_NAME", externalv1alpha1.ControllerName)
externalProviderName := envOrDefault("EXTERNAL_PROVIDER_NAME", externalv1alpha1.ProviderName)
externalProviderVersion := envOrDefault("EXTERNAL_PROVIDER_VERSION", externalv1alpha1.ProviderVersion)
hostedControllerName := envOrDefault("HOSTED_CONTROLLER_NAME", hostedv1alpha1.ControllerName)
hostedProviderName := envOrDefault("HOSTED_PROVIDER_NAME", hostedv1alpha1.ProviderName)
hostedProviderVersion := envOrDefault("HOSTED_PROVIDER_VERSION", hostedv1alpha1.ProviderVersion)
hostedEndpointTTL := envUintOrDefault("HOSTED_ENDPOINT_TTL", uint(hostedv1alpha1.DefaultTTL))
var externalDNSQPS float64
var externalDNSBurst int

Expand All @@ -127,12 +137,21 @@ func main() {
flag.StringVar(&externalProviderVersion, "external-provider-version", externalProviderVersion, "Provider version handled by the external controller.")
flag.Float64Var(&externalDNSQPS, "external-dns-qps", externalprobe.DefaultQPS, "Maximum external DNS query rate per controller process.")
flag.IntVar(&externalDNSBurst, "external-dns-burst", externalprobe.DefaultBurst, "Maximum external DNS query burst per controller process.")
flag.BoolVar(&hostedControllerEnabled, "hosted-controller-enabled", hostedControllerEnabled, "Enable hosted authoritative DNS controllers and endpoint conversion.")
flag.StringVar(&hostedControllerName, "hosted-controller-name", hostedControllerName, "ZoneClass.spec.controllerName handled by the hosted controller.")
flag.StringVar(&hostedProviderName, "hosted-provider-name", hostedProviderName, "Provider.metadata.name handled by the hosted controller.")
flag.StringVar(&hostedProviderVersion, "hosted-provider-version", hostedProviderVersion, "Provider version handled by the hosted controller.")
flag.UintVar(&hostedEndpointTTL, "hosted-endpoint-ttl", hostedEndpointTTL, "TTL in seconds for hosted endpoint-converted records (1-86400).")

zapOpts := zap.Options{Development: true}
zapOpts.BindFlags(flag.CommandLine)
flag.Parse()

ctrl.SetLogger(zap.New(zap.UseFlagOptions(&zapOpts)))
if hostedControllerEnabled && (hostedEndpointTTL < 1 || hostedEndpointTTL > 86400) {
ctrl.Log.Error(nil, "hosted endpoint TTL must be between 1 and 86400 seconds")
os.Exit(1)
}

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Expand Down Expand Up @@ -246,6 +265,34 @@ func main() {
}
}

if hostedControllerEnabled {
if err := (&hostedzoneclass.ZoneClassReconciler{
Client: mgr.GetClient(),
ControllerName: hostedControllerName,
ProviderName: hostedProviderName,
ProviderVersion: hostedProviderVersion,
}).SetupWithManager(mgr); err != nil {
ctrl.Log.Error(err, "unable to set up hosted ZoneClass controller")
os.Exit(1)
}

hostedReconciler, err := hostedzoneunit.NewReconciler(mgr.GetClient(), hostedzoneunit.Options{
ControllerName: hostedControllerName,
ProviderName: hostedProviderName,
ProviderVersion: hostedProviderVersion,
DefaultViews: []string{envOrDefault("HOSTED_SERVER_ADDRESS",
"dns-api-hosted-server."+envOrDefault("POD_NAMESPACE", "dns-api-system")+".svc:53")},
})
if err != nil {
ctrl.Log.Error(err, "unable to configure hosted DNS resolver")
os.Exit(1)
}
if err := hostedReconciler.SetupWithManager(mgr); err != nil {
ctrl.Log.Error(err, "unable to set up hosted ZoneUnit controller")
os.Exit(1)
}
}

if err := (&zoneunitcontroller.ZoneUnitCompositionReconciler{
Client: mgr.GetClient(),
}).SetupWithManager(mgr); err != nil {
Expand Down Expand Up @@ -307,6 +354,9 @@ func main() {
if externalControllerEnabled {
externalconversion.NewHandler().Register(mgr.GetWebhookServer())
}
if hostedControllerEnabled {
hostedconversion.NewHandler(uint32(hostedEndpointTTL)).Register(mgr.GetWebhookServer())
}

if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
ctrl.Log.Error(err, "unable to set up health check")
Expand Down Expand Up @@ -343,3 +393,16 @@ func envBoolOrDefault(name string, fallback bool) bool {
}
return enabled
}

func envUintOrDefault(name string, fallback uint) uint {
value := os.Getenv(name)
if value == "" {
return fallback
}
parsed, err := strconv.ParseUint(value, 10, 32)
if err != nil {
ctrl.Log.Error(err, "invalid unsigned integer environment variable", "name", name)
os.Exit(1)
}
return uint(parsed)
}
28 changes: 28 additions & 0 deletions app/operator/cmd/manager/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"testing"

externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1"
hostedv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/hosted/v1alpha1"
)

func TestProviderEnablementEnvironmentDefaults(t *testing.T) {
Expand All @@ -17,6 +18,7 @@ func TestProviderEnablementEnvironmentDefaults(t *testing.T) {
"CLOUDFLARE_CONTROLLER_ENABLED": true,
"ROUTE53_ENDPOINT_CONVERSION_ENABLED": true,
"EXTERNAL_CONTROLLER_ENABLED": false,
"HOSTED_CONTROLLER_ENABLED": false,
} {
t.Run(name, func(t *testing.T) {
for _, test := range []struct {
Expand All @@ -42,6 +44,12 @@ func TestExternalSchemeRegistered(t *testing.T) {
}
}

func TestHostedSchemeRegistered(t *testing.T) {
if !scheme.IsVersionRegistered(hostedv1alpha1.SchemeGroupVersion) {
t.Fatal("hosted API scheme is not registered")
}
}

// Run the real manager's flag binding in a subprocess. Help exits before any
// Kubernetes client, resolver, controller, or webhook is constructed.
func TestManagerFlagsProcess(t *testing.T) {
Expand Down Expand Up @@ -82,6 +90,11 @@ func TestManagerProviderFlags(t *testing.T) {
"external-provider-version": "v1alpha1",
"external-dns-qps": "40",
"external-dns-burst": "80",
"hosted-controller-enabled": "false",
"hosted-controller-name": "hosted.dns.appthrust.io/controller",
"hosted-provider-name": "hosted.dns.appthrust.io",
"hosted-provider-version": "v1alpha1",
"hosted-endpoint-ttl": "60",
}
for _, mode := range []string{"defaults", "environment", "flags-override-environment"} {
t.Run(mode, func(t *testing.T) {
Expand All @@ -98,6 +111,8 @@ func TestManagerProviderFlags(t *testing.T) {
if fallback == "false" {
value = "true"
}
} else if name == "hosted-endpoint-ttl" {
value = "120"
} else {
value = "environment-" + name
}
Expand Down Expand Up @@ -137,3 +152,16 @@ func TestManagerProviderFlags(t *testing.T) {
})
}
}

func TestHostedEndpointTTLRejectedBeforeManagerStartup(t *testing.T) {
for _, value := range []string{"0", "86401"} {
t.Run(value, func(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=^TestManagerFlagsProcess$", "--", "--hosted-controller-enabled=true", "--hosted-endpoint-ttl="+value)
cmd.Env = append(os.Environ(), "DNS_API_TEST_MANAGER_FLAGS=1", "HOSTED_ENDPOINT_TTL=")
output, err := cmd.CombinedOutput()
if err == nil || !strings.Contains(string(output), "hosted endpoint TTL must be between 1 and 86400 seconds") {
t.Fatalf("unexpected invalid TTL result: %v\n%s", err, output)
}
})
}
}
Loading
Loading