mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-25 03:28:42 +09:00
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
335 lines
9.1 KiB
Go
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)
|
|
}
|