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 Dockerfile patches the cloned root Cargo.toml to expose
ts_control's insecure-keyfetch feature through the tailscale crate
so the axum example can fetch the control key over plain HTTP.
The integration harness serves the control plane without TLS,
which is the only mode tailscale-rs can register against until it
grows a way to inject a custom CA bundle.
This commit is contained in:
Kristoffer Dalby 2026-04-28 08:52:34 +00:00
parent 2e1a716a9a
commit ea968e2f5d
2 changed files with 360 additions and 0 deletions

26
Dockerfile.tailscale-rs Normal file
View File

@ -0,0 +1,26 @@
FROM rust:1.94-bookworm AS builder
ARG TAILSCALE_RS_REPO=https://github.com/tailscale/tailscale-rs.git
ARG TAILSCALE_RS_REF=main
WORKDIR /app
RUN git clone --depth 1 --branch "$TAILSCALE_RS_REF" "$TAILSCALE_RS_REPO" .
# Re-export ts_control's insecure-keyfetch feature through the tailscale
# crate so the axum example can fetch the headscale control key over
# plain HTTP. The integration harness serves the control plane without
# TLS, and upstream only allows plain-HTTP key fetches when this Cargo
# feature is compiled in.
RUN sed -i '/^axum = \["dep:axum"\]/a insecure-keyfetch = ["ts_control/insecure-keyfetch"]' Cargo.toml
RUN cargo build --release --features axum,insecure-keyfetch --example axum
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
iproute2 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/examples/axum /usr/local/bin/axum

334
integration/tsric/tsric.go Normal file
View File

@ -0,0 +1,334 @@
// Package tsric provides a TailscaleRustInContainer (tsric) implementation
// that runs the tailscale-rs axum example inside a Docker container for
// integration testing with headscale.
//
// Unlike tsic (which runs the official Tailscale client), tsric runs a Rust
// implementation of a Tailscale node. It does not have the `tailscale` CLI,
// so verification is done externally via headscale API and peer connectivity.
package tsric
import (
"errors"
"fmt"
"io"
"log"
"os"
"strings"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/juanfont/headscale/integration/dockertestutil"
"github.com/juanfont/headscale/integration/integrationutil"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
)
const (
tsricHashLength = 6
caCertRoot = "/usr/local/share/ca-certificates"
dockerfileName = "Dockerfile.tailscale-rs"
dockerContextPath = "../."
buildArgRepo = "TAILSCALE_RS_REPO"
buildArgRef = "TAILSCALE_RS_REF"
)
// getPrebuiltImage returns the pre-built tailscale-rs Docker image name if set.
func getPrebuiltImage() string {
return os.Getenv("HEADSCALE_INTEGRATION_TAILSCALE_RS_IMAGE")
}
// TailscaleRustInContainer runs the tailscale-rs axum example as an
// integration test peer.
type TailscaleRustInContainer struct {
hostname string
pool *dockertest.Pool
container *dockertest.Resource
network *dockertest.Network
caCerts [][]byte
headscaleURL string
authKey string
extraHosts []string
repo string
ref string
}
// Option represents optional settings for a TailscaleRustInContainer instance.
type Option = func(c *TailscaleRustInContainer)
// WithCACert adds a CA certificate to the trusted certificates of the container.
func WithCACert(cert []byte) Option {
return func(t *TailscaleRustInContainer) {
t.caCerts = append(t.caCerts, cert)
}
}
// WithNetwork sets the Docker container network.
func WithNetwork(network *dockertest.Network) Option {
return func(t *TailscaleRustInContainer) {
t.network = network
}
}
// WithHeadscaleURL sets the headscale control server URL.
func WithHeadscaleURL(url string) Option {
return func(t *TailscaleRustInContainer) {
t.headscaleURL = url
}
}
// WithAuthKey sets the pre-authentication key for joining the tailnet.
func WithAuthKey(key string) Option {
return func(t *TailscaleRustInContainer) {
t.authKey = key
}
}
// WithExtraHosts adds extra /etc/hosts entries to the container.
func WithExtraHosts(hosts []string) Option {
return func(t *TailscaleRustInContainer) {
t.extraHosts = append(t.extraHosts, hosts...)
}
}
// WithRepo overrides the tailscale-rs git repository URL used by the
// Dockerfile. Defaults to the public github.com/tailscale/tailscale-rs.
func WithRepo(url string) Option {
return func(t *TailscaleRustInContainer) {
t.repo = url
}
}
// WithRef overrides the tailscale-rs git ref (branch, tag, commit) used
// by the Dockerfile. Defaults to "main".
func WithRef(ref string) Option {
return func(t *TailscaleRustInContainer) {
t.ref = ref
}
}
// buildEntrypoint constructs the container entrypoint command.
//
// The axum example reads the control URL from TS_CONTROL_URL, the
// hostname from -H, and the auth key from -k. The key file (-c) is
// created on first run.
func (t *TailscaleRustInContainer) buildEntrypoint() []string {
var commands []string
commands = append(commands,
"while ! ip route show default >/dev/null 2>&1; do sleep 0.1; done")
// CA certs are written by New after the container starts, so the
// entrypoint races with that write. Block until the first cert lands.
if len(t.caCerts) > 0 {
commands = append(commands,
fmt.Sprintf("while [ ! -f %s/user-0.crt ]; do sleep 0.1; done", caCertRoot))
}
commands = append(commands, "update-ca-certificates 2>/dev/null || true")
commands = append(commands,
fmt.Sprintf(`export TS_CONTROL_URL=%q`, t.headscaleURL),
// The tailscale crate refuses to run without this env gate;
// see lib.rs in tailscale-rs.
"export TS_RS_EXPERIMENT=this_is_unstable_software",
)
axumCmd := "/usr/local/bin/axum -c /tmp/tsrs-keys.json -H " + t.hostname
if t.authKey != "" {
axumCmd += " -k " + t.authKey
}
commands = append(commands, "exec "+axumCmd)
return []string{"/bin/sh", "-c", strings.Join(commands, " ; ")}
}
// New creates and starts a new TailscaleRustInContainer instance.
func New(
pool *dockertest.Pool,
opts ...Option,
) (*TailscaleRustInContainer, error) {
hash, err := util.GenerateRandomStringDNSSafe(tsricHashLength)
if err != nil {
return nil, err
}
runID := dockertestutil.GetIntegrationRunID()
var hostname string
if runID != "" {
runIDShort := runID[len(runID)-6:]
hostname = fmt.Sprintf("tsrs-%s-%s", runIDShort, hash)
} else {
hostname = "tsrs-" + hash
}
t := &TailscaleRustInContainer{
hostname: hostname,
pool: pool,
}
for _, opt := range opts {
opt(t)
}
if t.network == nil {
return nil, errors.New("tsric: no network set") //nolint:err113
}
if t.headscaleURL == "" {
return nil, errors.New("tsric: no headscale URL set") //nolint:err113
}
if t.authKey == "" {
return nil, errors.New("tsric: no auth key set") //nolint:err113
}
entrypoint := t.buildEntrypoint()
runOptions := &dockertest.RunOptions{
Name: hostname,
Networks: []*dockertest.Network{t.network},
Entrypoint: entrypoint,
ExtraHosts: append(t.extraHosts, "host.docker.internal:host-gateway"),
Env: []string{},
}
dockertestutil.DockerAddIntegrationLabels(runOptions, "tailscale-rs")
err = pool.RemoveContainerByName(hostname)
if err != nil {
return nil, err
}
var container *dockertest.Resource
if prebuiltImage := getPrebuiltImage(); prebuiltImage != "" {
log.Printf("Using pre-built tailscale-rs image: %s", prebuiltImage)
repo, tag, ok := strings.Cut(prebuiltImage, ":")
if !ok {
return nil, fmt.Errorf("tsric: invalid image format %q, expected repository:tag", prebuiltImage) //nolint:err113
}
runOptions.Repository = repo
runOptions.Tag = tag
container, err = pool.RunWithOptions(
runOptions,
dockertestutil.DockerRestartPolicy,
dockertestutil.DockerAllowLocalIPv6,
dockertestutil.DockerMemoryLimit,
)
if err != nil {
return nil, fmt.Errorf(
"tsric: could not start pre-built tailscale-rs container %s: %w",
hostname, err,
)
}
} else {
// Build from the Dockerfile so callers don't need a local
// tailscale-rs checkout; the Dockerfile clones at build time.
var buildArgs []docker.BuildArg
if t.repo != "" {
buildArgs = append(buildArgs, docker.BuildArg{Name: buildArgRepo, Value: t.repo})
}
if t.ref != "" {
buildArgs = append(buildArgs, docker.BuildArg{Name: buildArgRef, Value: t.ref})
}
buildOptions := &dockertest.BuildOptions{
Dockerfile: dockerfileName,
ContextDir: dockerContextPath,
BuildArgs: buildArgs,
}
log.Printf("Building tailscale-rs container %s from upstream (this may take a while for the first build)...", hostname)
container, err = pool.BuildAndRunWithBuildOptions(
buildOptions,
runOptions,
dockertestutil.DockerRestartPolicy,
dockertestutil.DockerAllowLocalIPv6,
dockertestutil.DockerMemoryLimit,
)
if err != nil {
return nil, fmt.Errorf(
"tsric: could not build and start tailscale-rs container %s: %w",
hostname, err,
)
}
}
log.Printf("Created tailscale-rs container %s", hostname)
t.container = container
for i, cert := range t.caCerts {
err = t.WriteFile(fmt.Sprintf("%s/user-%d.crt", caCertRoot, i), cert)
if err != nil {
return nil, fmt.Errorf("writing TLS certificate to container: %w", err)
}
}
return t, nil
}
// Hostname returns the hostname of the TailscaleRustInContainer instance.
func (t *TailscaleRustInContainer) Hostname() string {
return t.hostname
}
// ContainerID returns the Docker container ID.
func (t *TailscaleRustInContainer) ContainerID() string {
return t.container.Container.ID
}
// Shutdown stops and cleans up the container.
func (t *TailscaleRustInContainer) Shutdown() (string, string, error) {
stdoutPath, stderrPath, err := t.SaveLog("/tmp/control")
if err != nil {
log.Printf(
"saving log from %s: %s",
t.hostname,
fmt.Errorf("saving log: %w", err),
)
}
return stdoutPath, stderrPath, t.pool.Purge(t.container)
}
// SaveLog saves the current container logs to the given path.
func (t *TailscaleRustInContainer) SaveLog(path string) (string, string, error) {
return dockertestutil.SaveLog(t.pool, t.container, path)
}
// WriteLogs writes the current stdout/stderr log of the container to
// the given io.Writers.
func (t *TailscaleRustInContainer) WriteLogs(stdout, stderr io.Writer) error {
return dockertestutil.WriteLog(t.pool, t.container, stdout, stderr)
}
// Execute runs a command inside the container.
func (t *TailscaleRustInContainer) Execute(
command []string,
options ...dockertestutil.ExecuteCommandOption,
) (string, string, error) {
return dockertestutil.ExecuteCommand(
t.container,
command,
[]string{},
options...,
)
}
// WriteFile writes a file into the container.
func (t *TailscaleRustInContainer) WriteFile(path string, data []byte) error {
return integrationutil.WriteFileToContainer(t.pool, t.container, path, data)
}