Run the real operator in a single-container k3s cluster against an in-test Headscale over plain HTTP. k3sic exposes reusable building blocks (InstallOperator, DeployConnector, DeployEchoServer, ExposeServiceToTailnet, DeployProxyGroup); the test covers operator registration, an egress connector, ingress connectivity from a tailnet node, and proxy groups. tls-ca-baking.md records the private-CA TLS variant. Updates #1202
3.1 KiB
Running the operator against a private-CA TLS Headscale
TestK8sOperator points the Tailscale Kubernetes operator at an HTTP
Headscale (hsic.WithoutTLS, loginServer = http://<ip>:<port>). That keeps
the harness small: the operator and proxy pods need no CA, so there is no image
baking, no containerd import, and no CoreDNS hostname mapping.
This note records how to run the same test against a TLS Headscale serving a private CA, in case a future test needs to exercise realistic TLS. It is not wired up; reconstruct it from here.
Why it is involved
tailscaled/tsnet verify the control connection against Go's
x509.SystemCertPool (on the Alpine images: /etc/ssl/certs/ca-certificates.crt).
A private CA must therefore be in the pod's system trust store.
As of the tailscale operator chart on main, there is no supported way to hand
a CA file to a proxy pod:
ProxyClass.spec.statefulSet.podhas novolumes.ProxyClass...tailscaleContainerhas novolumeMounts, and itsenvis a reduced schema (name/valueonly — novalueFrom/envFrom).operatorConfigexposesextraEnvbut noextraVolumes.
SSL_CERT_FILE/SSL_CERT_DIR are honoured by tailscaled, but they need a
file that cannot be projected in. So the CA has to be baked into the images.
The recipe
-
Serve Headscale with TLS (the
hsicdefault) and grabheadscale.GetCert(). Pass it to the cluster so in-containerhelm/kubectltrust it. -
Bake the CA into derived operator and proxy images. For each of
tailscale/k8s-operator:<tag>andtailscale/tailscale:<tag>, build:FROM <base> COPY headscale-ca.crt /usr/local/share/ca-certificates/headscale-ca.crt RUN update-ca-certificatesBuild on the host docker daemon (it has egress to pull the base images),
ExportImagethe result to a docker-format tarball, stream it into the k3s container, and import it into the kubelet's containerd namespace:ctr --namespace k8s.io images import <tarball>Tag the derived images
headscale.local/...:<tag>-caand run them withimagePullPolicy: Never(theheadscale.local/prefix is never resolved by a registry). -
Wire the derived images into the chart via
operatorConfig.image/proxyConfig.image. The operator's proxy StatefulSet template hard-codesimagePullPolicy: Always, so aProxyClassmust override the proxy image and setimagePullPolicy: Never; Connectors must reference that ProxyClass explicitly (defaultProxyClassdoes not apply to them). -
Pods resolve Headscale through CoreDNS, not the container's
/etc/hosts, and the cert's only SAN is the Headscale hostname (dialing by IP fails TLS verification). Install acoredns-customConfigMap mapping the hostname to the Headscale IP and gate on thekube-dnsService having ready endpoints before starting the operator.
The full implementation lived in integration/k3sic/k3sic.go and
integration/k8s_operator_test.go before the switch to HTTP; recover it from
git history (PrepareTailscaleImages, bakeAndImportImage, caBuildContext,
ConfigureCoreDNSHost) if needed.