Skip to content

Commit 025be4c

Browse files
stubbiclaude
andcommitted
feat(tailscale): add ephemeral Tailscale sidecar that Serves the app
Add spec.tailscale (enabled, mode=serve|funnel, image, authKey.secretRef, hostname) and an ephemeral userspace Tailscale sidecar that Serves the Paperclip app (port 3100) over the tailnet via TS_SERVE_CONFIG. The node runs with --ephemeral so it is removed from the tailnet when the pod is deleted, and the sidecar runs with a read-only root filesystem and all capabilities dropped. The serve config is rendered into a managed ConfigMap and mounted into the sidecar; funnel mode additionally sets AllowFunnel. The Instance reconciler provisions the ConfigMap (surfacing a TailscaleReady condition) and the NetworkPolicy gains STUN (3478/udp) and WireGuard (41641/udp) egress when Tailscale is enabled (443/tcp for DERP/control is already allowed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 56060ca commit 025be4c

2 files changed

Lines changed: 365 additions & 0 deletions

File tree

internal/resources/tailscale.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package resources
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
7+
corev1 "k8s.io/api/core/v1"
8+
9+
paperclipv1alpha1 "github.com/paperclipinc/paperclip-operator/api/v1alpha1"
10+
)
11+
12+
const (
13+
// DefaultTailscaleImage is the default image repository for the Tailscale sidecar.
14+
DefaultTailscaleImage = "ghcr.io/tailscale/tailscale"
15+
// DefaultTailscaleTag is the default Tailscale sidecar image tag.
16+
DefaultTailscaleTag = "stable"
17+
// DefaultTailscaleAuthKeyKey is the default Secret key holding the auth key.
18+
DefaultTailscaleAuthKeyKey = "authkey"
19+
20+
// TailscaleContainerName is the name of the Tailscale sidecar container.
21+
TailscaleContainerName = "tailscale"
22+
23+
// TailscaleModeServe exposes the instance to tailnet members only.
24+
TailscaleModeServe = "serve"
25+
// TailscaleModeFunnel exposes the instance to the public internet via Funnel.
26+
TailscaleModeFunnel = "funnel"
27+
28+
// TailscaleStatePath is the directory where tailscaled stores state. Placed
29+
// under an emptyDir so tailscaled owns the path with a read-only root fs.
30+
TailscaleStatePath = "/tmp/tailscale"
31+
// TailscaleSocketDir is the directory containing the tailscaled Unix socket.
32+
TailscaleSocketDir = "/var/run/tailscale"
33+
// TailscaleSocketPath is the full path to the tailscaled Unix socket.
34+
TailscaleSocketPath = "/var/run/tailscale/tailscaled.sock"
35+
36+
// TailscaleServeConfigKey is the ConfigMap data key for the serve config JSON.
37+
TailscaleServeConfigKey = "tailscale-serve.json"
38+
// tailscaleSocketVolumeName is the emptyDir volume holding the tailscaled socket.
39+
tailscaleSocketVolumeName = "tailscale-socket"
40+
// tailscaleStateVolumeName is the emptyDir volume holding tailscaled state.
41+
tailscaleStateVolumeName = "tailscale-tmp"
42+
// tailscaleConfigVolumeName is the ConfigMap volume holding the serve config.
43+
tailscaleConfigVolumeName = "tailscale-config"
44+
)
45+
46+
// TailscaleConfigMapName returns the name of the Tailscale serve-config ConfigMap.
47+
func TailscaleConfigMapName(instance *paperclipv1alpha1.Instance) string {
48+
return instance.Name + "-tailscale"
49+
}
50+
51+
// tailscaleServeConfig is the JSON structure for TS_SERVE_CONFIG.
52+
type tailscaleServeConfig struct {
53+
TCP map[string]*tailscaleTCPHandler `json:"TCP"`
54+
Web map[string]*tailscaleWebConfig `json:"Web,omitempty"`
55+
// AllowFunnel controls whether Tailscale Funnel (public internet) is enabled.
56+
AllowFunnel map[string]bool `json:"AllowFunnel,omitempty"`
57+
}
58+
59+
type tailscaleTCPHandler struct {
60+
HTTPS bool `json:"HTTPS"`
61+
}
62+
63+
type tailscaleWebConfig struct {
64+
Handlers map[string]*tailscaleWebHandler `json:"Handlers"`
65+
}
66+
67+
type tailscaleWebHandler struct {
68+
Proxy string `json:"Proxy"`
69+
}
70+
71+
// GetTailscaleImage returns the full Tailscale sidecar image reference.
72+
func GetTailscaleImage(instance *paperclipv1alpha1.Instance) string {
73+
repo := instance.Spec.Tailscale.Image.Repository
74+
if repo == "" {
75+
repo = DefaultTailscaleImage
76+
}
77+
if instance.Spec.Tailscale.Image.Digest != "" {
78+
return repo + "@" + instance.Spec.Tailscale.Image.Digest
79+
}
80+
tag := instance.Spec.Tailscale.Image.Tag
81+
if tag == "" {
82+
tag = DefaultTailscaleTag
83+
}
84+
return repo + ":" + tag
85+
}
86+
87+
// TailscaleHostname returns the configured Tailscale device name, defaulting to
88+
// the instance name.
89+
func TailscaleHostname(instance *paperclipv1alpha1.Instance) string {
90+
if instance.Spec.Tailscale.Hostname != "" {
91+
return instance.Spec.Tailscale.Hostname
92+
}
93+
return instance.Name
94+
}
95+
96+
// BuildTailscaleServeConfig returns the TS_SERVE_CONFIG JSON that proxies the
97+
// tailnet HTTPS endpoint to the local Paperclip app port. Funnel is enabled
98+
// when mode is "funnel".
99+
func BuildTailscaleServeConfig(instance *paperclipv1alpha1.Instance) string {
100+
proxy := fmt.Sprintf("http://127.0.0.1:%d", servicePort(instance))
101+
102+
cfg := tailscaleServeConfig{
103+
TCP: map[string]*tailscaleTCPHandler{
104+
"443": {HTTPS: true},
105+
},
106+
Web: map[string]*tailscaleWebConfig{
107+
"${TS_CERT_DOMAIN}:443": {
108+
Handlers: map[string]*tailscaleWebHandler{
109+
"/": {Proxy: proxy},
110+
},
111+
},
112+
},
113+
}
114+
115+
mode := instance.Spec.Tailscale.Mode
116+
if mode == "" {
117+
mode = TailscaleModeServe
118+
}
119+
if mode == TailscaleModeFunnel {
120+
cfg.AllowFunnel = map[string]bool{
121+
"${TS_CERT_DOMAIN}:443": true,
122+
}
123+
}
124+
125+
data, _ := json.Marshal(cfg)
126+
return string(data)
127+
}
128+
129+
// BuildTailscaleContainer builds the ephemeral Tailscale sidecar container that
130+
// runs userspace tailscaled and Serves the Paperclip app over the tailnet.
131+
func BuildTailscaleContainer(instance *paperclipv1alpha1.Instance) corev1.Container {
132+
env := []corev1.EnvVar{
133+
{Name: "TS_USERSPACE", Value: "true"},
134+
{Name: "TS_STATE_DIR", Value: TailscaleStatePath},
135+
{Name: "TS_SOCKET", Value: TailscaleSocketPath},
136+
{Name: "TS_SERVE_CONFIG", Value: "/etc/tailscale/serve/" + TailscaleServeConfigKey},
137+
{Name: "TS_HOSTNAME", Value: TailscaleHostname(instance)},
138+
// Ephemeral nodes are removed from the tailnet when the pod is deleted.
139+
{Name: "TS_EXTRA_ARGS", Value: "--ephemeral"},
140+
}
141+
142+
if instance.Spec.Tailscale.AuthKey != nil {
143+
key := instance.Spec.Tailscale.AuthKey.Key
144+
if key == "" {
145+
key = DefaultTailscaleAuthKeyKey
146+
}
147+
env = append(env, corev1.EnvVar{
148+
Name: "TS_AUTHKEY",
149+
ValueFrom: &corev1.EnvVarSource{
150+
SecretKeyRef: &corev1.SecretKeySelector{
151+
LocalObjectReference: instance.Spec.Tailscale.AuthKey.SecretRef,
152+
Key: key,
153+
},
154+
},
155+
})
156+
}
157+
158+
return corev1.Container{
159+
Name: TailscaleContainerName,
160+
Image: GetTailscaleImage(instance),
161+
ImagePullPolicy: corev1.PullIfNotPresent,
162+
Env: env,
163+
VolumeMounts: []corev1.VolumeMount{
164+
{
165+
Name: tailscaleSocketVolumeName,
166+
MountPath: TailscaleSocketDir,
167+
},
168+
{
169+
Name: tailscaleConfigVolumeName,
170+
MountPath: "/etc/tailscale/serve/" + TailscaleServeConfigKey,
171+
SubPath: TailscaleServeConfigKey,
172+
ReadOnly: true,
173+
},
174+
{
175+
Name: tailscaleStateVolumeName,
176+
MountPath: "/tmp",
177+
},
178+
},
179+
Resources: instance.Spec.Tailscale.Resources,
180+
SecurityContext: &corev1.SecurityContext{
181+
AllowPrivilegeEscalation: Ptr(false),
182+
ReadOnlyRootFilesystem: Ptr(true),
183+
RunAsNonRoot: Ptr(true),
184+
Capabilities: &corev1.Capabilities{
185+
Drop: []corev1.Capability{"ALL"},
186+
},
187+
SeccompProfile: &corev1.SeccompProfile{
188+
Type: corev1.SeccompProfileTypeRuntimeDefault,
189+
},
190+
},
191+
}
192+
}
193+
194+
// TailscaleVolumes returns the volumes required by the Tailscale sidecar: two
195+
// emptyDirs (socket dir and state dir) and the serve-config ConfigMap.
196+
func TailscaleVolumes(instance *paperclipv1alpha1.Instance) []corev1.Volume {
197+
return []corev1.Volume{
198+
{
199+
Name: tailscaleSocketVolumeName,
200+
VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}},
201+
},
202+
{
203+
Name: tailscaleStateVolumeName,
204+
VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}},
205+
},
206+
{
207+
Name: tailscaleConfigVolumeName,
208+
VolumeSource: corev1.VolumeSource{
209+
ConfigMap: &corev1.ConfigMapVolumeSource{
210+
LocalObjectReference: corev1.LocalObjectReference{
211+
Name: TailscaleConfigMapName(instance),
212+
},
213+
},
214+
},
215+
},
216+
}
217+
}
218+
219+
// BuildTailscaleConfigMap builds the ConfigMap holding the TS_SERVE_CONFIG JSON
220+
// mounted into the Tailscale sidecar.
221+
func BuildTailscaleConfigMap(instance *paperclipv1alpha1.Instance) *corev1.ConfigMap {
222+
return &corev1.ConfigMap{
223+
ObjectMeta: ObjectMeta(instance, TailscaleConfigMapName(instance)),
224+
Data: map[string]string{
225+
TailscaleServeConfigKey: BuildTailscaleServeConfig(instance),
226+
},
227+
}
228+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package resources
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
8+
corev1 "k8s.io/api/core/v1"
9+
10+
paperclipv1alpha1 "github.com/paperclipinc/paperclip-operator/api/v1alpha1"
11+
)
12+
13+
func TestGetTailscaleImage(t *testing.T) {
14+
inst := newTestInstance("pc")
15+
if got := GetTailscaleImage(inst); got != DefaultTailscaleImage+":"+DefaultTailscaleTag {
16+
t.Errorf("default image = %q", got)
17+
}
18+
19+
inst.Spec.Tailscale.Image.Repository = "example.com/ts"
20+
inst.Spec.Tailscale.Image.Tag = "v1.2.3"
21+
if got := GetTailscaleImage(inst); got != "example.com/ts:v1.2.3" {
22+
t.Errorf("custom image = %q", got)
23+
}
24+
25+
inst.Spec.Tailscale.Image.Digest = "sha256:deadbeef"
26+
if got := GetTailscaleImage(inst); got != "example.com/ts@sha256:deadbeef" {
27+
t.Errorf("digest image = %q", got)
28+
}
29+
}
30+
31+
func TestTailscaleHostnameDefaultsToInstanceName(t *testing.T) {
32+
inst := newTestInstance("my-pc")
33+
if got := TailscaleHostname(inst); got != "my-pc" {
34+
t.Errorf("hostname = %q", got)
35+
}
36+
inst.Spec.Tailscale.Hostname = "custom"
37+
if got := TailscaleHostname(inst); got != "custom" {
38+
t.Errorf("hostname = %q", got)
39+
}
40+
}
41+
42+
func TestBuildTailscaleServeConfig_ServeMode(t *testing.T) {
43+
inst := newTestInstance("pc")
44+
inst.Spec.Tailscale.Mode = TailscaleModeServe
45+
46+
raw := BuildTailscaleServeConfig(inst)
47+
var cfg tailscaleServeConfig
48+
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
49+
t.Fatalf("invalid serve config JSON: %v", err)
50+
}
51+
if cfg.TCP["443"] == nil || !cfg.TCP["443"].HTTPS {
52+
t.Error("expected HTTPS on 443")
53+
}
54+
web := cfg.Web["${TS_CERT_DOMAIN}:443"]
55+
if web == nil || web.Handlers["/"] == nil {
56+
t.Fatal("expected web handler for /")
57+
}
58+
if !strings.Contains(web.Handlers["/"].Proxy, "3100") {
59+
t.Errorf("proxy should target app port 3100, got %q", web.Handlers["/"].Proxy)
60+
}
61+
if len(cfg.AllowFunnel) != 0 {
62+
t.Error("serve mode should not enable funnel")
63+
}
64+
}
65+
66+
func TestBuildTailscaleServeConfig_FunnelMode(t *testing.T) {
67+
inst := newTestInstance("pc")
68+
inst.Spec.Tailscale.Mode = TailscaleModeFunnel
69+
70+
raw := BuildTailscaleServeConfig(inst)
71+
var cfg tailscaleServeConfig
72+
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
73+
t.Fatalf("invalid serve config JSON: %v", err)
74+
}
75+
if !cfg.AllowFunnel["${TS_CERT_DOMAIN}:443"] {
76+
t.Error("funnel mode should enable AllowFunnel")
77+
}
78+
}
79+
80+
func TestBuildTailscaleContainer(t *testing.T) {
81+
inst := newTestInstance("pc")
82+
inst.Spec.Tailscale.Enabled = true
83+
inst.Spec.Tailscale.AuthKey = &paperclipv1alpha1.TailscaleAuthKeySpec{
84+
SecretRef: corev1.LocalObjectReference{Name: "ts-secret"},
85+
}
86+
87+
c := BuildTailscaleContainer(inst)
88+
if c.Name != TailscaleContainerName {
89+
t.Errorf("name = %q", c.Name)
90+
}
91+
92+
env := map[string]corev1.EnvVar{}
93+
for _, e := range c.Env {
94+
env[e.Name] = e
95+
}
96+
if env["TS_USERSPACE"].Value != "true" {
97+
t.Error("expected userspace mode")
98+
}
99+
if !strings.Contains(env["TS_EXTRA_ARGS"].Value, "--ephemeral") {
100+
t.Errorf("expected ephemeral node, got %q", env["TS_EXTRA_ARGS"].Value)
101+
}
102+
ak := env["TS_AUTHKEY"]
103+
if ak.ValueFrom == nil || ak.ValueFrom.SecretKeyRef == nil {
104+
t.Fatal("expected TS_AUTHKEY from secret")
105+
}
106+
if ak.ValueFrom.SecretKeyRef.Name != "ts-secret" || ak.ValueFrom.SecretKeyRef.Key != DefaultTailscaleAuthKeyKey {
107+
t.Errorf("auth key ref = %+v", ak.ValueFrom.SecretKeyRef)
108+
}
109+
if c.SecurityContext == nil || c.SecurityContext.ReadOnlyRootFilesystem == nil || !*c.SecurityContext.ReadOnlyRootFilesystem {
110+
t.Error("expected read-only root filesystem")
111+
}
112+
}
113+
114+
func TestBuildTailscaleConfigMapAndVolumes(t *testing.T) {
115+
inst := newTestInstance("pc")
116+
cm := BuildTailscaleConfigMap(inst)
117+
if cm.Name != TailscaleConfigMapName(inst) {
118+
t.Errorf("configmap name = %q", cm.Name)
119+
}
120+
if _, ok := cm.Data[TailscaleServeConfigKey]; !ok {
121+
t.Error("configmap missing serve config key")
122+
}
123+
124+
vols := TailscaleVolumes(inst)
125+
if len(vols) != 3 {
126+
t.Fatalf("expected 3 volumes, got %d", len(vols))
127+
}
128+
var hasConfig bool
129+
for _, v := range vols {
130+
if v.ConfigMap != nil && v.ConfigMap.Name == TailscaleConfigMapName(inst) {
131+
hasConfig = true
132+
}
133+
}
134+
if !hasConfig {
135+
t.Error("expected a configmap volume referencing the serve config")
136+
}
137+
}

0 commit comments

Comments
 (0)