Skip to content

Commit 55452e0

Browse files
committed
integration/tsric: add TailscaleRustInContainer package
Add TailscaleRustInContainer (tsric), a Rust-client counterpart to integration/tsic. It runs the axum example from tailscale-rs in a Docker container and exposes the same lifecycle hooks as tsic (Shutdown, SaveLog, Execute, WriteFile) so integration tests can treat it as any other Tailscale node. Dockerfile.tailscale-rs clones tailscale-rs at build time, so no local source checkout is required. The repo URL and ref are Docker build arguments (TAILSCALE_RS_REPO, TAILSCALE_RS_REF) exposed as tsric.WithRepo / tsric.WithRef options. The HEADSCALE_INTEGRATION_ TAILSCALE_RS_IMAGE environment variable provides an escape hatch for using a pre-built image instead of building from source. The default ref is upstream main. Two upstream changes wire the example to a local headscale: - tailscale/tailscale-rs#109 — env fallback for control URL and auth key in the axum example (merged 2026-04-21) - tailscale/tailscale-rs#111 — plain-HTTP control-key fetching behind the ts_control insecure-keyfetch Cargo feature (merged 2026-04-22) The Dockerfile patches the cloned root Cargo.toml to expose ts_control's insecure-keyfetch through the tailscale crate so it can be enabled when building the axum example, since headscale's integration harness runs the control plane over plain HTTP.
1 parent 3672a2d commit 55452e0

2 files changed

Lines changed: 377 additions & 0 deletions

File tree

Dockerfile.tailscale-rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
FROM rust:1.94-bookworm AS builder
2+
3+
# Build the tailscale-rs axum example from upstream main. The env var and
4+
# plain-HTTP control support needed to target a local headscale landed in
5+
# upstream PRs #109 and #111.
6+
ARG TAILSCALE_RS_REPO=https://github.com/tailscale/tailscale-rs.git
7+
ARG TAILSCALE_RS_REF=main
8+
9+
WORKDIR /app
10+
RUN git clone --depth 1 --branch "$TAILSCALE_RS_REF" "$TAILSCALE_RS_REPO" .
11+
12+
# Headscale runs the control plane over plain HTTP in integration tests.
13+
# Upstream tailscale-rs gates plain-HTTP control-key fetching behind
14+
# ts_control's `insecure-keyfetch` Cargo feature; expose it through the
15+
# root `tailscale` crate so it can be enabled when building the example.
16+
RUN sed -i '/^axum = \["dep:axum"\]/a insecure-keyfetch = ["ts_control/insecure-keyfetch"]' Cargo.toml
17+
18+
RUN cargo build --release --features axum,insecure-keyfetch --example axum
19+
20+
FROM debian:bookworm-slim
21+
22+
RUN apt-get update && \
23+
apt-get install -y --no-install-recommends \
24+
ca-certificates \
25+
iproute2 \
26+
&& rm -rf /var/lib/apt/lists/*
27+
28+
COPY --from=builder /app/target/release/examples/axum /usr/local/bin/axum

integration/tsric/tsric.go

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
// Package tsric provides a TailscaleRustInContainer (tsric) implementation
2+
// that runs the tailscale-rs axum example inside a Docker container for
3+
// integration testing with headscale.
4+
//
5+
// Unlike tsic (which runs the official Tailscale client), tsric runs a Rust
6+
// implementation of a Tailscale node. It does not have the `tailscale` CLI,
7+
// so verification is done externally via headscale API and peer connectivity.
8+
package tsric
9+
10+
import (
11+
"errors"
12+
"fmt"
13+
"io"
14+
"log"
15+
"os"
16+
"strings"
17+
18+
"github.com/juanfont/headscale/hscontrol/util"
19+
"github.com/juanfont/headscale/integration/dockertestutil"
20+
"github.com/juanfont/headscale/integration/integrationutil"
21+
"github.com/ory/dockertest/v3"
22+
"github.com/ory/dockertest/v3/docker"
23+
)
24+
25+
const (
26+
tsricHashLength = 6
27+
caCertRoot = "/usr/local/share/ca-certificates"
28+
29+
// dockerfileName is the name of the Dockerfile used to build the
30+
// tailscale-rs axum container. It lives in the headscale repo root
31+
// and clones tailscale-rs from the public upstream repository.
32+
dockerfileName = "Dockerfile.tailscale-rs"
33+
34+
// dockerContextPath is the path from integration/ to the headscale
35+
// repo root where the Dockerfile lives and acts as the build context.
36+
dockerContextPath = "../."
37+
38+
// Build-arg names forwarded to the Dockerfile for sourcing tailscale-rs.
39+
buildArgRepo = "TAILSCALE_RS_REPO"
40+
buildArgRef = "TAILSCALE_RS_REF"
41+
)
42+
43+
// getPrebuiltImage returns the pre-built tailscale-rs Docker image name if set.
44+
func getPrebuiltImage() string {
45+
return os.Getenv("HEADSCALE_INTEGRATION_TAILSCALE_RS_IMAGE")
46+
}
47+
48+
// TailscaleRustInContainer is a Docker container running the tailscale-rs
49+
// axum example, which joins a tailnet and serves HTTP on port 80.
50+
type TailscaleRustInContainer struct {
51+
hostname string
52+
53+
pool *dockertest.Pool
54+
container *dockertest.Resource
55+
network *dockertest.Network
56+
57+
// Configuration
58+
caCerts [][]byte
59+
headscaleURL string
60+
authKey string
61+
extraHosts []string
62+
repo string
63+
ref string
64+
}
65+
66+
// Option represents optional settings for a TailscaleRustInContainer instance.
67+
type Option = func(c *TailscaleRustInContainer)
68+
69+
// WithCACert adds a CA certificate to the trusted certificates of the container.
70+
func WithCACert(cert []byte) Option {
71+
return func(t *TailscaleRustInContainer) {
72+
t.caCerts = append(t.caCerts, cert)
73+
}
74+
}
75+
76+
// WithNetwork sets the Docker container network.
77+
func WithNetwork(network *dockertest.Network) Option {
78+
return func(t *TailscaleRustInContainer) {
79+
t.network = network
80+
}
81+
}
82+
83+
// WithHeadscaleURL sets the headscale control server URL.
84+
func WithHeadscaleURL(url string) Option {
85+
return func(t *TailscaleRustInContainer) {
86+
t.headscaleURL = url
87+
}
88+
}
89+
90+
// WithAuthKey sets the pre-authentication key for joining the tailnet.
91+
func WithAuthKey(key string) Option {
92+
return func(t *TailscaleRustInContainer) {
93+
t.authKey = key
94+
}
95+
}
96+
97+
// WithExtraHosts adds extra /etc/hosts entries to the container.
98+
func WithExtraHosts(hosts []string) Option {
99+
return func(t *TailscaleRustInContainer) {
100+
t.extraHosts = append(t.extraHosts, hosts...)
101+
}
102+
}
103+
104+
// WithRepo overrides the tailscale-rs git repository URL used by the
105+
// Dockerfile. Defaults to the public github.com/tailscale/tailscale-rs.
106+
func WithRepo(url string) Option {
107+
return func(t *TailscaleRustInContainer) {
108+
t.repo = url
109+
}
110+
}
111+
112+
// WithRef overrides the tailscale-rs git ref (branch, tag, commit) used
113+
// by the Dockerfile. Defaults to "main".
114+
func WithRef(ref string) Option {
115+
return func(t *TailscaleRustInContainer) {
116+
t.ref = ref
117+
}
118+
}
119+
120+
// buildEntrypoint constructs the container entrypoint command.
121+
//
122+
// The upstream axum example reads the control-server URL from the
123+
// TS_CONTROL_URL environment variable (clap env-arg) and takes the
124+
// requested hostname from the -H/--hostname flag. The auth key is
125+
// passed via -k. The key file (-c) is created on first run
126+
// automatically.
127+
func (t *TailscaleRustInContainer) buildEntrypoint() []string {
128+
var commands []string
129+
130+
// Wait for network to be ready
131+
commands = append(commands,
132+
"while ! ip route show default >/dev/null 2>&1; do sleep 0.1; done")
133+
134+
// If CA certs are configured, wait for them to be written
135+
if len(t.caCerts) > 0 {
136+
commands = append(commands,
137+
fmt.Sprintf("while [ ! -f %s/user-0.crt ]; do sleep 0.1; done", caCertRoot))
138+
}
139+
140+
// Update CA certificates
141+
commands = append(commands, "update-ca-certificates 2>/dev/null || true")
142+
143+
commands = append(commands,
144+
fmt.Sprintf(`export TS_CONTROL_URL=%q`, t.headscaleURL),
145+
"export TS_RS_EXPERIMENT=this_is_unstable_software",
146+
)
147+
148+
axumCmd := "/usr/local/bin/axum -c /tmp/tsrs-keys.json -H " + t.hostname
149+
if t.authKey != "" {
150+
axumCmd += " -k " + t.authKey
151+
}
152+
153+
commands = append(commands, "exec "+axumCmd)
154+
155+
return []string{"/bin/sh", "-c", strings.Join(commands, " ; ")}
156+
}
157+
158+
// New creates and starts a new TailscaleRustInContainer instance.
159+
func New(
160+
pool *dockertest.Pool,
161+
opts ...Option,
162+
) (*TailscaleRustInContainer, error) {
163+
hash, err := util.GenerateRandomStringDNSSafe(tsricHashLength)
164+
if err != nil {
165+
return nil, err
166+
}
167+
168+
runID := dockertestutil.GetIntegrationRunID()
169+
170+
var hostname string
171+
172+
if runID != "" {
173+
runIDShort := runID[len(runID)-6:]
174+
hostname = fmt.Sprintf("tsrs-%s-%s", runIDShort, hash)
175+
} else {
176+
hostname = "tsrs-" + hash
177+
}
178+
179+
t := &TailscaleRustInContainer{
180+
hostname: hostname,
181+
pool: pool,
182+
}
183+
184+
for _, opt := range opts {
185+
opt(t)
186+
}
187+
188+
if t.network == nil {
189+
return nil, errors.New("tsric: no network set") //nolint:err113
190+
}
191+
192+
if t.headscaleURL == "" {
193+
return nil, errors.New("tsric: no headscale URL set") //nolint:err113
194+
}
195+
196+
if t.authKey == "" {
197+
return nil, errors.New("tsric: no auth key set") //nolint:err113
198+
}
199+
200+
// Build the entrypoint
201+
entrypoint := t.buildEntrypoint()
202+
203+
runOptions := &dockertest.RunOptions{
204+
Name: hostname,
205+
Networks: []*dockertest.Network{t.network},
206+
Entrypoint: entrypoint,
207+
ExtraHosts: append(t.extraHosts, "host.docker.internal:host-gateway"),
208+
Env: []string{},
209+
}
210+
211+
// Add integration test labels
212+
dockertestutil.DockerAddIntegrationLabels(runOptions, "tailscale-rs")
213+
214+
// Remove any existing container with this name
215+
err = pool.RemoveContainerByName(hostname)
216+
if err != nil {
217+
return nil, err
218+
}
219+
220+
var container *dockertest.Resource
221+
222+
if prebuiltImage := getPrebuiltImage(); prebuiltImage != "" {
223+
// Use a pre-built image (set via HEADSCALE_INTEGRATION_TAILSCALE_RS_IMAGE)
224+
log.Printf("Using pre-built tailscale-rs image: %s", prebuiltImage)
225+
226+
repo, tag, ok := strings.Cut(prebuiltImage, ":")
227+
if !ok {
228+
return nil, fmt.Errorf("tsric: invalid image format %q, expected repository:tag", prebuiltImage) //nolint:err113
229+
}
230+
231+
runOptions.Repository = repo
232+
runOptions.Tag = tag
233+
234+
container, err = pool.RunWithOptions(
235+
runOptions,
236+
dockertestutil.DockerRestartPolicy,
237+
dockertestutil.DockerAllowLocalIPv6,
238+
dockertestutil.DockerMemoryLimit,
239+
)
240+
if err != nil {
241+
return nil, fmt.Errorf(
242+
"tsric: could not start pre-built tailscale-rs container %s: %w",
243+
hostname, err,
244+
)
245+
}
246+
} else {
247+
// Build from the Dockerfile in the headscale repo root. The Dockerfile
248+
// clones tailscale-rs from the public upstream repository at build
249+
// time, so no local source checkout is required.
250+
var buildArgs []docker.BuildArg
251+
252+
if t.repo != "" {
253+
buildArgs = append(buildArgs, docker.BuildArg{Name: buildArgRepo, Value: t.repo})
254+
}
255+
256+
if t.ref != "" {
257+
buildArgs = append(buildArgs, docker.BuildArg{Name: buildArgRef, Value: t.ref})
258+
}
259+
260+
buildOptions := &dockertest.BuildOptions{
261+
Dockerfile: dockerfileName,
262+
ContextDir: dockerContextPath,
263+
BuildArgs: buildArgs,
264+
}
265+
266+
log.Printf("Building tailscale-rs container %s from upstream (this may take a while for the first build)...", hostname)
267+
268+
container, err = pool.BuildAndRunWithBuildOptions(
269+
buildOptions,
270+
runOptions,
271+
dockertestutil.DockerRestartPolicy,
272+
dockertestutil.DockerAllowLocalIPv6,
273+
dockertestutil.DockerMemoryLimit,
274+
)
275+
if err != nil {
276+
return nil, fmt.Errorf(
277+
"tsric: could not build and start tailscale-rs container %s: %w",
278+
hostname, err,
279+
)
280+
}
281+
}
282+
283+
log.Printf("Created tailscale-rs container %s", hostname)
284+
285+
t.container = container
286+
287+
// Write CA certificates into the container
288+
for i, cert := range t.caCerts {
289+
err = t.WriteFile(fmt.Sprintf("%s/user-%d.crt", caCertRoot, i), cert)
290+
if err != nil {
291+
return nil, fmt.Errorf("writing TLS certificate to container: %w", err)
292+
}
293+
}
294+
295+
return t, nil
296+
}
297+
298+
// Hostname returns the hostname of the TailscaleRustInContainer instance.
299+
func (t *TailscaleRustInContainer) Hostname() string {
300+
return t.hostname
301+
}
302+
303+
// ContainerID returns the Docker container ID.
304+
func (t *TailscaleRustInContainer) ContainerID() string {
305+
return t.container.Container.ID
306+
}
307+
308+
// Shutdown stops and cleans up the container.
309+
func (t *TailscaleRustInContainer) Shutdown() (string, string, error) {
310+
stdoutPath, stderrPath, err := t.SaveLog("/tmp/control")
311+
if err != nil {
312+
log.Printf(
313+
"saving log from %s: %s",
314+
t.hostname,
315+
fmt.Errorf("saving log: %w", err),
316+
)
317+
}
318+
319+
return stdoutPath, stderrPath, t.pool.Purge(t.container)
320+
}
321+
322+
// SaveLog saves the current container logs to the given path.
323+
func (t *TailscaleRustInContainer) SaveLog(path string) (string, string, error) {
324+
return dockertestutil.SaveLog(t.pool, t.container, path)
325+
}
326+
327+
// WriteLogs writes the current stdout/stderr log of the container to
328+
// the given io.Writers.
329+
func (t *TailscaleRustInContainer) WriteLogs(stdout, stderr io.Writer) error {
330+
return dockertestutil.WriteLog(t.pool, t.container, stdout, stderr)
331+
}
332+
333+
// Execute runs a command inside the container.
334+
func (t *TailscaleRustInContainer) Execute(
335+
command []string,
336+
options ...dockertestutil.ExecuteCommandOption,
337+
) (string, string, error) {
338+
return dockertestutil.ExecuteCommand(
339+
t.container,
340+
command,
341+
[]string{},
342+
options...,
343+
)
344+
}
345+
346+
// WriteFile writes a file into the container.
347+
func (t *TailscaleRustInContainer) WriteFile(path string, data []byte) error {
348+
return integrationutil.WriteFileToContainer(t.pool, t.container, path, data)
349+
}

0 commit comments

Comments
 (0)