Files
headscale/integration/tsric/tsric.go
Kristoffer Dalby 4cca63155d all: apply godoc [Name] link conventions across comments
Every Go-identifier reference in // and /* */ comments now uses
godoc's [Name] linking syntax so pkg.go.dev and `go doc` render
them as clickable cross-references. No behaviour change.

Pattern applied across the tree:
  In-package         [Foo], [Foo.Bar]
  Cross-package      [pkg.Foo], [pkg.Foo.Bar]
  Stdlib             [netip.Prefix], [errors.Is], [context.Context]
  Tailscale          [tailcfg.MapResponse], [tailcfg.Node.CapMap],
                     [tailcfg.NodeAttrSuggestExitNode]

Skip rules:
  - File:line refs left as plain text
  - HuJSON wire keys inside backtick raw strings untouched
  - ACL/policy syntax tokens (tag:foo, autogroup:self, ...) not Go
    symbols, left as plain text
  - JSON/OIDC wire keys, gorm tags, RFC IPv6 placeholders, markdown
    link tags, decorative dividers — all left as-is
2026-05-19 09:55:22 +02:00

335 lines
9.1 KiB
Go

// 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 [dockertest.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.Writer]s.
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)
}