mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-05 03:56:10 +02:00
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:
parent
2e1a716a9a
commit
ea968e2f5d
26
Dockerfile.tailscale-rs
Normal file
26
Dockerfile.tailscale-rs
Normal 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
334
integration/tsric/tsric.go
Normal 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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user