2022-10-13 16:00:08 +02:00
package tsic
import (
2024-08-19 11:41:05 +02:00
"archive/tar"
"bytes"
2024-02-18 23:22:18 +01:00
"context"
2022-10-18 11:58:49 +02:00
"encoding/json"
2022-10-13 16:00:08 +02:00
"errors"
"fmt"
2024-02-18 23:22:18 +01:00
"io"
2022-10-13 16:00:08 +02:00
"log"
"net/netip"
2022-11-03 16:56:19 +01:00
"net/url"
2024-02-23 10:59:24 +01:00
"os"
2024-11-22 11:57:01 +01:00
"reflect"
2025-03-21 11:49:32 +01:00
"runtime/debug"
2025-12-15 12:40:59 +00:00
"slices"
2023-02-02 16:05:52 +01:00
"strconv"
2022-10-13 16:00:08 +02:00
"strings"
2023-02-02 16:05:52 +01:00
"time"
2022-10-13 16:00:08 +02:00
2025-10-23 17:57:41 +02:00
"github.com/cenkalti/backoff/v5"
2025-04-30 08:54:04 +03:00
"github.com/juanfont/headscale/hscontrol/types"
2023-05-11 09:09:18 +02:00
"github.com/juanfont/headscale/hscontrol/util"
2022-10-13 16:00:08 +02:00
"github.com/juanfont/headscale/integration/dockertestutil"
2022-11-06 20:22:21 +01:00
"github.com/juanfont/headscale/integration/integrationutil"
2022-10-13 16:00:08 +02:00
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
2024-02-18 23:22:18 +01:00
"tailscale.com/ipn"
2022-10-18 11:58:49 +02:00
"tailscale.com/ipn/ipnstate"
2025-06-18 15:24:53 +08:00
"tailscale.com/ipn/store/mem"
2024-02-09 07:26:41 +01:00
"tailscale.com/net/netcheck"
2025-06-18 15:24:53 +08:00
"tailscale.com/paths"
"tailscale.com/types/key"
2024-01-18 17:30:25 +01:00
"tailscale.com/types/netmap"
2025-07-23 16:03:58 +02:00
"tailscale.com/util/multierr"
2025-10-23 17:57:41 +02:00
"tailscale.com/wgengine/filter"
2022-10-13 16:00:08 +02:00
)
2022-10-18 12:09:10 +02:00
const (
2023-09-10 10:00:12 +02:00
tsicHashLength = 6
2025-08-06 08:37:02 +02:00
defaultPingTimeout = 200 * time . Millisecond
defaultPingCount = 5
2023-09-10 10:00:12 +02:00
dockerContextPath = "../."
2024-11-22 20:23:05 +08:00
caCertRoot = "/usr/local/share/ca-certificates"
2023-09-10 10:00:12 +02:00
dockerExecuteTimeout = 60 * time . Second
2022-10-18 12:09:10 +02:00
)
2022-10-13 16:00:08 +02:00
2022-10-18 12:09:10 +02:00
var (
2022-11-03 16:56:19 +01:00
errTailscalePingFailed = errors . New ( "ping failed" )
2023-04-13 21:09:09 +00:00
errTailscalePingNotDERP = errors . New ( "ping not via DERP" )
2022-11-03 16:56:19 +01:00
errTailscaleNotLoggedIn = errors . New ( "tailscale not logged in" )
errTailscaleWrongPeerCount = errors . New ( "wrong peer count" )
errTailscaleCannotUpWithoutAuthkey = errors . New ( "cannot up without authkey" )
errTailscaleNotConnected = errors . New ( "tailscale not connected" )
2023-08-29 08:33:33 +02:00
errTailscaledNotReadyForLogin = errors . New ( "tailscaled not ready for login" )
2024-11-22 11:57:01 +01:00
errInvalidClientConfig = errors . New ( "verifiably invalid client config requested" )
2025-12-12 23:01:52 +01:00
errInvalidTailscaleImageFormat = errors . New ( "invalid HEADSCALE_INTEGRATION_TAILSCALE_IMAGE format, expected repository:tag" )
errTailscaleImageRequiredInCI = errors . New ( "HEADSCALE_INTEGRATION_TAILSCALE_IMAGE must be set in CI for HEAD version" )
2025-12-15 12:40:59 +00:00
errContainerNotInitialized = errors . New ( "container not initialized" )
errFQDNNotYetAvailable = errors . New ( "FQDN not yet available" )
2024-11-22 11:57:01 +01:00
)
const (
VersionHead = "head"
2022-10-18 12:09:10 +02:00
)
2022-10-13 16:00:08 +02:00
2023-08-29 08:33:33 +02:00
func errTailscaleStatus ( hostname string , err error ) error {
return fmt . Errorf ( "%s failed to fetch tailscale status: %w" , hostname , err )
}
2023-02-03 12:24:27 +01:00
// TailscaleInContainer is an implementation of TailscaleClient which
// sets up a Tailscale instance inside a container.
2022-10-13 16:00:08 +02:00
type TailscaleInContainer struct {
version string
2022-10-21 13:17:54 +02:00
hostname string
2022-10-13 16:00:08 +02:00
pool * dockertest . Pool
container * dockertest . Resource
network * dockertest . Network
2022-10-23 14:13:22 +02:00
// "cache"
ips [ ] netip . Addr
fqdn string
2022-11-06 20:22:21 +01:00
// optional config
2024-11-22 20:23:05 +08:00
caCerts [ ] [ ] byte
2022-11-06 20:22:21 +01:00
headscaleHostname string
2024-09-21 12:05:36 +02:00
withWebsocketDERP bool
2022-11-08 15:10:03 +00:00
withSSH bool
2023-02-02 16:05:52 +01:00
withTags [ ] string
2023-03-20 08:52:52 +01:00
withEntrypoint [ ] string
2023-04-13 21:09:09 +00:00
withExtraHosts [ ] string
2023-03-20 08:52:52 +01:00
workdir string
2023-08-31 18:37:18 +02:00
netfilter string
2025-02-23 14:10:25 -08:00
extraLoginArgs [ ] string
2025-03-21 11:49:32 +01:00
withAcceptRoutes bool
2025-12-15 12:40:59 +00:00
withPackages [ ] string // Alpine packages to install at container start
withWebserverPort int // Port for built-in HTTP server (0 = disabled)
withExtraCommands [ ] string // Extra shell commands to run before tailscaled
2024-11-22 11:57:01 +01:00
// build options, solely for HEAD
buildConfig TailscaleInContainerBuildConfig
}
type TailscaleInContainerBuildConfig struct {
tags [ ] string
2022-11-06 20:22:21 +01:00
}
2023-02-03 12:24:27 +01:00
// Option represent optional settings that can be given to a
// Tailscale instance.
2022-11-06 20:22:21 +01:00
type Option = func ( c * TailscaleInContainer )
2024-11-22 20:23:05 +08:00
// WithCACert adds it to the trusted surtificate of the Tailscale container.
func WithCACert ( cert [ ] byte ) Option {
2022-11-06 20:22:21 +01:00
return func ( tsic * TailscaleInContainer ) {
2024-11-22 20:23:05 +08:00
tsic . caCerts = append ( tsic . caCerts , cert )
2022-11-06 20:22:21 +01:00
}
}
2025-03-21 11:49:32 +01:00
// WithNetwork sets the Docker container network to use with
// the Tailscale instance.
func WithNetwork ( network * dockertest . Network ) Option {
2022-11-06 20:22:21 +01:00
return func ( tsic * TailscaleInContainer ) {
tsic . network = network
}
}
2023-02-03 12:24:27 +01:00
// WithHeadscaleName set the name of the headscale instance,
2024-11-22 20:23:05 +08:00
// mostly useful in combination with TLS and WithCACert.
2022-11-06 20:22:21 +01:00
func WithHeadscaleName ( hsName string ) Option {
return func ( tsic * TailscaleInContainer ) {
tsic . headscaleHostname = hsName
}
2022-10-13 16:00:08 +02:00
}
2023-02-03 12:24:27 +01:00
// WithTags associates the given tags to the Tailscale instance.
2023-02-02 16:05:52 +01:00
func WithTags ( tags [ ] string ) Option {
return func ( tsic * TailscaleInContainer ) {
tsic . withTags = tags
}
}
2024-09-21 12:05:36 +02:00
// WithWebsocketDERP toggles a development knob to
// force enable DERP connection through the new websocket protocol.
func WithWebsocketDERP ( enabled bool ) Option {
return func ( tsic * TailscaleInContainer ) {
tsic . withWebsocketDERP = enabled
}
}
2023-02-03 12:24:27 +01:00
// WithSSH enables SSH for the Tailscale instance.
2022-11-08 15:10:03 +00:00
func WithSSH ( ) Option {
return func ( tsic * TailscaleInContainer ) {
tsic . withSSH = true
}
}
2023-03-20 08:52:52 +01:00
// WithDockerWorkdir allows the docker working directory to be set.
func WithDockerWorkdir ( dir string ) Option {
return func ( tsic * TailscaleInContainer ) {
tsic . workdir = dir
}
}
2023-04-13 21:09:09 +00:00
func WithExtraHosts ( hosts [ ] string ) Option {
return func ( tsic * TailscaleInContainer ) {
tsic . withExtraHosts = hosts
}
}
2023-03-20 08:52:52 +01:00
// WithDockerEntrypoint allows the docker entrypoint of the container
// to be overridden. This is a dangerous option which can make
// the container not work as intended as a typo might prevent
// tailscaled and other processes from starting.
// Use with caution.
func WithDockerEntrypoint ( args [ ] string ) Option {
return func ( tsic * TailscaleInContainer ) {
tsic . withEntrypoint = args
}
}
2023-08-31 18:37:18 +02:00
// WithNetfilter configures Tailscales parameter --netfilter-mode
// allowing us to turn of modifying ip[6]tables/nftables.
// It takes: "on", "off", "nodivert".
func WithNetfilter ( state string ) Option {
return func ( tsic * TailscaleInContainer ) {
tsic . netfilter = state
}
}
2024-11-22 11:57:01 +01:00
// WithBuildTag adds an additional value to the `-tags=` parameter
// of the Go compiler, allowing callers to customize the Tailscale client build.
// This option is only meaningful when invoked on **HEAD** versions of the client.
// Attempts to use it with any other version is a bug in the calling code.
func WithBuildTag ( tag string ) Option {
return func ( tsic * TailscaleInContainer ) {
if tsic . version != VersionHead {
panic ( errInvalidClientConfig )
}
tsic . buildConfig . tags = append (
tsic . buildConfig . tags , tag ,
)
}
}
2025-02-23 14:10:25 -08:00
// WithExtraLoginArgs adds additional arguments to the `tailscale up` command
// as part of the Login function.
func WithExtraLoginArgs ( args [ ] string ) Option {
return func ( tsic * TailscaleInContainer ) {
2025-04-30 08:54:04 +03:00
tsic . extraLoginArgs = append ( tsic . extraLoginArgs , args ... )
2025-02-23 14:10:25 -08:00
}
}
2025-07-21 03:51:57 +08:00
// WithAcceptRoutes tells the node to accept incoming routes.
2025-03-21 11:49:32 +01:00
func WithAcceptRoutes ( ) Option {
return func ( tsic * TailscaleInContainer ) {
tsic . withAcceptRoutes = true
}
}
2025-12-15 12:40:59 +00:00
// WithPackages specifies Alpine packages to install when the container starts.
// This requires internet access and uses `apk add`. Common packages:
// - "python3" for HTTP server
// - "curl" for HTTP client
// - "bind-tools" for dig command
// - "iptables", "ip6tables" for firewall rules
// Note: Tests using this option require internet access and cannot use
// the built-in DERP server in offline mode.
func WithPackages ( packages ... string ) Option {
return func ( tsic * TailscaleInContainer ) {
tsic . withPackages = append ( tsic . withPackages , packages ... )
}
}
// WithWebserver starts a Python HTTP server on the specified port
// alongside tailscaled. This is useful for testing subnet routing
// and ACL connectivity. Automatically adds "python3" to packages if needed.
// The server serves files from the root directory (/).
func WithWebserver ( port int ) Option {
return func ( tsic * TailscaleInContainer ) {
tsic . withWebserverPort = port
}
}
// WithExtraCommands adds extra shell commands to run before tailscaled starts.
// Commands are run after package installation and CA certificate updates.
func WithExtraCommands ( commands ... string ) Option {
return func ( tsic * TailscaleInContainer ) {
tsic . withExtraCommands = append ( tsic . withExtraCommands , commands ... )
}
}
// buildEntrypoint constructs the container entrypoint command based on
// configured options (packages, webserver, etc.).
func ( t * TailscaleInContainer ) buildEntrypoint ( ) [ ] string {
var commands [ ] string
// Wait for network to be ready
commands = append ( commands , "while ! ip route show default >/dev/null 2>&1; do sleep 0.1; done" )
// If CA certs are configured, wait for them to be written by the Go code
// (certs are written after container start via tsic.WriteFile)
if len ( t . caCerts ) > 0 {
commands = append ( commands ,
fmt . Sprintf ( "while [ ! -f %s/user-0.crt ]; do sleep 0.1; done" , caCertRoot ) )
}
// Install packages if requested (requires internet access)
packages := t . withPackages
if t . withWebserverPort > 0 && ! slices . Contains ( packages , "python3" ) {
packages = append ( packages , "python3" )
}
if len ( packages ) > 0 {
commands = append ( commands , "apk add --no-cache " + strings . Join ( packages , " " ) )
}
// Update CA certificates
commands = append ( commands , "update-ca-certificates" )
// Run extra commands if any
commands = append ( commands , t . withExtraCommands ... )
// Start webserver in background if requested
// Use subshell to avoid & interfering with command joining
if t . withWebserverPort > 0 {
commands = append ( commands ,
fmt . Sprintf ( "(python3 -m http.server --bind :: %d &)" , t . withWebserverPort ) )
}
// Start tailscaled (must be last as it's the foreground process)
commands = append ( commands , "tailscaled --tun=tsdev --verbose=10" )
return [ ] string { "/bin/sh" , "-c" , strings . Join ( commands , " ; " ) }
}
2023-02-03 12:24:27 +01:00
// New returns a new TailscaleInContainer instance.
2022-10-13 16:00:08 +02:00
func New (
pool * dockertest . Pool ,
version string ,
2022-11-06 20:22:21 +01:00
opts ... Option ,
2022-10-18 12:09:10 +02:00
) ( * TailscaleInContainer , error ) {
2023-05-11 09:09:18 +02:00
hash , err := util . GenerateRandomStringDNSSafe ( tsicHashLength )
2022-10-13 16:00:08 +02:00
if err != nil {
return nil , err
}
2022-10-21 14:07:46 +02:00
hostname := fmt . Sprintf ( "ts-%s-%s" , strings . ReplaceAll ( version , "." , "-" ) , hash )
2022-10-13 16:00:08 +02:00
2022-11-06 20:22:21 +01:00
tsic := & TailscaleInContainer {
version : version ,
hostname : hostname ,
2025-03-21 11:49:32 +01:00
pool : pool ,
2022-11-06 20:22:21 +01:00
}
for _ , opt := range opts {
opt ( tsic )
}
2022-10-13 16:00:08 +02:00
2025-12-15 12:40:59 +00:00
// Build the entrypoint command dynamically based on options.
// Only build if no custom entrypoint was provided via WithDockerEntrypoint.
if len ( tsic . withEntrypoint ) == 0 {
tsic . withEntrypoint = tsic . buildEntrypoint ( )
}
2025-03-21 11:49:32 +01:00
if tsic . network == nil {
return nil , fmt . Errorf ( "no network set, called from: \n%s" , string ( debug . Stack ( ) ) )
}
2022-10-13 16:00:08 +02:00
tailscaleOptions := & dockertest . RunOptions {
2024-12-09 17:15:38 +01:00
Name : hostname ,
Networks : [ ] * dockertest . Network { tsic . network } ,
2023-03-20 08:52:52 +01:00
Entrypoint : tsic . withEntrypoint ,
2023-04-13 21:09:09 +00:00
ExtraHosts : tsic . withExtraHosts ,
2024-09-21 12:05:36 +02:00
Env : [ ] string { } ,
}
if tsic . withWebsocketDERP {
2024-11-22 11:57:01 +01:00
if version != VersionHead {
return tsic , errInvalidClientConfig
}
WithBuildTag ( "ts_debug_websockets" ) ( tsic )
2024-09-21 12:05:36 +02:00
tailscaleOptions . Env = append (
tailscaleOptions . Env ,
fmt . Sprintf ( "TS_DEBUG_DERP_WS_CLIENT=%t" , tsic . withWebsocketDERP ) ,
)
2022-10-13 16:00:08 +02:00
}
2024-11-22 20:23:05 +08:00
tailscaleOptions . ExtraHosts = append ( tailscaleOptions . ExtraHosts ,
"host.docker.internal:host-gateway" )
2022-11-06 20:22:21 +01:00
2023-03-20 08:52:52 +01:00
if tsic . workdir != "" {
tailscaleOptions . WorkingDir = tsic . workdir
}
2025-02-05 16:10:18 +01:00
// dockertest isn't very good at handling containers that has already
// been created, this is an attempt to make sure this container isn't
2022-10-13 16:00:08 +02:00
// present.
err = pool . RemoveContainerByName ( hostname )
if err != nil {
return nil , err
}
2025-06-23 13:43:14 +02:00
// Add integration test labels if running under hi tool
dockertestutil . DockerAddIntegrationLabels ( tailscaleOptions , "tailscale" )
2023-08-29 08:33:33 +02:00
var container * dockertest . Resource
2024-11-22 11:57:01 +01:00
if version != VersionHead {
// build options are not meaningful with pre-existing images,
// let's not lead anyone astray by pretending otherwise.
defaultBuildConfig := TailscaleInContainerBuildConfig { }
2025-12-15 12:40:59 +00:00
2024-11-22 11:57:01 +01:00
hasBuildConfig := ! reflect . DeepEqual ( defaultBuildConfig , tsic . buildConfig )
if hasBuildConfig {
return tsic , errInvalidClientConfig
}
}
2023-08-29 08:33:33 +02:00
switch version {
2024-11-22 11:57:01 +01:00
case VersionHead :
2025-12-12 23:01:52 +01:00
// Check if a pre-built image is available via environment variable
prebuiltImage := os . Getenv ( "HEADSCALE_INTEGRATION_TAILSCALE_IMAGE" )
// If custom build tags are required (e.g., for websocket DERP), we cannot use
// the pre-built image as it won't have the necessary code compiled in.
hasBuildTags := len ( tsic . buildConfig . tags ) > 0
if hasBuildTags && prebuiltImage != "" {
log . Printf ( "Ignoring pre-built image %s because custom build tags are required: %v" ,
prebuiltImage , tsic . buildConfig . tags )
prebuiltImage = ""
2023-08-29 08:33:33 +02:00
}
2025-12-12 23:01:52 +01:00
if prebuiltImage != "" {
log . Printf ( "Using pre-built tailscale image: %s" , prebuiltImage )
2025-11-28 16:59:54 +01:00
2025-12-12 23:01:52 +01:00
// Parse image into repository and tag
repo , tag , ok := strings . Cut ( prebuiltImage , ":" )
if ! ok {
return nil , errInvalidTailscaleImageFormat
}
2025-11-28 16:59:54 +01:00
2025-12-12 23:01:52 +01:00
tailscaleOptions . Repository = repo
tailscaleOptions . Tag = tag
2025-11-28 16:59:54 +01:00
2025-12-12 23:01:52 +01:00
container , err = pool . RunWithOptions (
tailscaleOptions ,
dockertestutil . DockerRestartPolicy ,
dockertestutil . DockerAllowLocalIPv6 ,
dockertestutil . DockerAllowNetworkAdministration ,
dockertestutil . DockerMemoryLimit ,
)
if err != nil {
return nil , fmt . Errorf ( "could not run pre-built tailscale container %q: %w" , prebuiltImage , err )
}
} else if util . IsCI ( ) && ! hasBuildTags {
// In CI, we require a pre-built image unless custom build tags are needed
return nil , errTailscaleImageRequiredInCI
} else {
buildOptions := & dockertest . BuildOptions {
Dockerfile : "Dockerfile.tailscale-HEAD" ,
ContextDir : dockerContextPath ,
BuildArgs : [ ] docker . BuildArg { } ,
2025-11-28 16:59:54 +01:00
}
2025-12-12 23:01:52 +01:00
buildTags := strings . Join ( tsic . buildConfig . tags , "," )
if len ( buildTags ) > 0 {
buildOptions . BuildArgs = append (
buildOptions . BuildArgs ,
docker . BuildArg {
Name : "BUILD_TAGS" ,
Value : buildTags ,
} ,
2025-11-28 16:59:54 +01:00
)
}
2025-12-12 23:01:52 +01:00
container , err = pool . BuildAndRunWithBuildOptions (
buildOptions ,
tailscaleOptions ,
dockertestutil . DockerRestartPolicy ,
dockertestutil . DockerAllowLocalIPv6 ,
dockertestutil . DockerAllowNetworkAdministration ,
dockertestutil . DockerMemoryLimit ,
)
if err != nil {
// Try to get more detailed build output
log . Printf ( "Docker build failed for %s, attempting to get detailed output..." , hostname )
buildOutput , buildErr := dockertestutil . RunDockerBuildForDiagnostics ( dockerContextPath , "Dockerfile.tailscale-HEAD" )
// Show the last 100 lines of build output to avoid overwhelming the logs
lines := strings . Split ( buildOutput , "\n" )
const maxLines = 100
startLine := 0
if len ( lines ) > maxLines {
startLine = len ( lines ) - maxLines
}
relevantOutput := strings . Join ( lines [ startLine : ] , "\n" )
if buildErr != nil {
// The diagnostic build also failed - this is the real error
return nil , fmt . Errorf (
"%s could not start tailscale container (version: %s): %w\n\nDocker build failed. Last %d lines of output:\n%s" ,
hostname ,
version ,
err ,
maxLines ,
relevantOutput ,
)
}
if buildOutput != "" {
// Build succeeded on retry but container creation still failed
return nil , fmt . Errorf (
"%s could not start tailscale container (version: %s): %w\n\nDocker build succeeded on retry, but container creation failed. Last %d lines of build output:\n%s" ,
hostname ,
version ,
err ,
maxLines ,
relevantOutput ,
)
}
// No output at all - diagnostic build command may have failed
2025-10-16 12:17:43 +02:00
return nil , fmt . Errorf (
2025-12-12 23:01:52 +01:00
"%s could not start tailscale container (version: %s): %w\n\nUnable to get diagnostic build output (command may have failed silently)" ,
2025-10-16 12:17:43 +02:00
hostname ,
version ,
err ,
)
}
}
2023-08-29 08:33:33 +02:00
case "unstable" :
tailscaleOptions . Repository = "tailscale/tailscale"
tailscaleOptions . Tag = version
container , err = pool . RunWithOptions (
tailscaleOptions ,
dockertestutil . DockerRestartPolicy ,
dockertestutil . DockerAllowLocalIPv6 ,
dockertestutil . DockerAllowNetworkAdministration ,
2025-07-28 11:15:53 +02:00
dockertestutil . DockerMemoryLimit ,
2023-08-29 08:33:33 +02:00
)
2025-10-16 12:17:43 +02:00
if err != nil {
log . Printf ( "Docker run failed for %s (unstable), error: %v" , hostname , err )
}
2023-08-29 08:33:33 +02:00
default :
tailscaleOptions . Repository = "tailscale/tailscale"
tailscaleOptions . Tag = "v" + version
container , err = pool . RunWithOptions (
tailscaleOptions ,
dockertestutil . DockerRestartPolicy ,
dockertestutil . DockerAllowLocalIPv6 ,
dockertestutil . DockerAllowNetworkAdministration ,
2025-07-28 11:15:53 +02:00
dockertestutil . DockerMemoryLimit ,
2023-08-29 08:33:33 +02:00
)
2025-10-16 12:17:43 +02:00
if err != nil {
log . Printf ( "Docker run failed for %s (version: v%s), error: %v" , hostname , version , err )
}
2023-08-29 08:33:33 +02:00
}
2022-10-13 16:00:08 +02:00
if err != nil {
2023-05-02 11:51:30 +02:00
return nil , fmt . Errorf (
2023-08-29 08:33:33 +02:00
"%s could not start tailscale container (version: %s): %w" ,
hostname ,
2023-05-02 11:51:30 +02:00
version ,
err ,
)
2022-10-13 16:00:08 +02:00
}
2025-12-15 12:40:59 +00:00
2022-10-13 16:00:08 +02:00
log . Printf ( "Created %s container\n" , hostname )
2022-11-06 20:22:21 +01:00
tsic . container = container
2024-11-22 20:23:05 +08:00
for i , cert := range tsic . caCerts {
err = tsic . WriteFile ( fmt . Sprintf ( "%s/user-%d.crt" , caCertRoot , i ) , cert )
2022-11-06 20:22:21 +01:00
if err != nil {
return nil , fmt . Errorf ( "failed to write TLS certificate to container: %w" , err )
}
}
2022-10-13 16:00:08 +02:00
2022-11-06 20:22:21 +01:00
return tsic , nil
}
2023-02-03 12:24:27 +01:00
// Shutdown stops and cleans up the Tailscale container.
2024-12-09 17:15:38 +01:00
func ( t * TailscaleInContainer ) Shutdown ( ) ( string , string , error ) {
stdoutPath , stderrPath , err := t . SaveLog ( "/tmp/control" )
2023-12-09 18:09:24 +01:00
if err != nil {
log . Printf (
"Failed to save log from %s: %s" ,
t . hostname ,
fmt . Errorf ( "failed to save log: %w" , err ) ,
)
}
2024-12-09 17:15:38 +01:00
return stdoutPath , stderrPath , t . pool . Purge ( t . container )
2022-10-13 16:00:08 +02:00
}
2023-02-03 12:24:27 +01:00
// Hostname returns the hostname of the Tailscale instance.
2022-10-21 13:17:54 +02:00
func ( t * TailscaleInContainer ) Hostname ( ) string {
return t . hostname
}
2023-02-03 12:24:27 +01:00
// Version returns the running Tailscale version of the instance.
2022-10-18 11:58:49 +02:00
func ( t * TailscaleInContainer ) Version ( ) string {
return t . version
}
2023-02-03 12:24:27 +01:00
// ID returns the Docker container ID of the TailscaleInContainer
// instance.
2025-04-30 08:54:04 +03:00
func ( t * TailscaleInContainer ) ContainerID ( ) string {
2022-11-08 15:09:52 +00:00
return t . container . Container . ID
}
2023-02-03 12:24:27 +01:00
// Execute runs a command inside the Tailscale container and returns the
// result of stdout as a string.
2022-10-23 14:13:22 +02:00
func ( t * TailscaleInContainer ) Execute (
command [ ] string ,
2023-04-13 21:09:09 +00:00
options ... dockertestutil . ExecuteCommandOption ,
2022-11-03 16:50:20 +01:00
) ( string , string , error ) {
2022-10-23 14:13:22 +02:00
stdout , stderr , err := dockertestutil . ExecuteCommand (
t . container ,
command ,
[ ] string { } ,
2023-04-13 21:09:09 +00:00
options ... ,
2022-10-23 14:13:22 +02:00
)
if err != nil {
2023-09-03 12:30:11 +02:00
// log.Printf("command issued: %s", strings.Join(command, " "))
2023-08-29 08:33:33 +02:00
// log.Printf("command stderr: %s\n", stderr)
2022-10-25 09:24:05 +02:00
if stdout != "" {
log . Printf ( "command stdout: %s\n" , stdout )
}
2022-10-23 14:13:22 +02:00
if strings . Contains ( stderr , "NeedsLogin" ) {
2022-11-03 16:50:20 +01:00
return stdout , stderr , errTailscaleNotLoggedIn
2022-10-23 14:13:22 +02:00
}
2022-11-03 16:50:20 +01:00
return stdout , stderr , err
2022-10-23 14:13:22 +02:00
}
2022-11-03 16:50:20 +01:00
return stdout , stderr , nil
2022-10-23 14:13:22 +02:00
}
2024-09-21 12:05:36 +02:00
// Retrieve container logs.
func ( t * TailscaleInContainer ) Logs ( stdout , stderr io . Writer ) error {
return dockertestutil . WriteLog (
t . pool ,
t . container ,
stdout , stderr ,
)
}
2025-04-30 08:54:04 +03:00
func ( t * TailscaleInContainer ) buildLoginCommand (
2022-10-13 16:00:08 +02:00
loginServer , authKey string ,
2025-04-30 08:54:04 +03:00
) [ ] string {
2022-10-13 16:00:08 +02:00
command := [ ] string {
"tailscale" ,
"up" ,
2023-08-29 08:33:33 +02:00
"--login-server=" + loginServer ,
"--hostname=" + t . hostname ,
2025-03-21 11:49:32 +01:00
fmt . Sprintf ( "--accept-routes=%t" , t . withAcceptRoutes ) ,
2022-10-13 16:00:08 +02:00
}
2025-04-30 08:54:04 +03:00
if authKey != "" {
command = append ( command , "--authkey=" + authKey )
}
2025-02-23 14:10:25 -08:00
if t . extraLoginArgs != nil {
command = append ( command , t . extraLoginArgs ... )
}
2022-11-08 15:10:03 +00:00
if t . withSSH {
command = append ( command , "--ssh" )
}
2023-08-31 18:37:18 +02:00
if t . netfilter != "" {
command = append ( command , "--netfilter-mode=" + t . netfilter )
}
2023-02-02 16:05:52 +01:00
if len ( t . withTags ) > 0 {
command = append ( command ,
2025-07-10 23:38:55 +02:00
"--advertise-tags=" + strings . Join ( t . withTags , "," ) ,
2023-02-02 16:05:52 +01:00
)
}
2025-04-30 08:54:04 +03:00
return command
}
// Login runs the login routine on the given Tailscale instance.
// This login mechanism uses the authorised key for authentication.
func ( t * TailscaleInContainer ) Login (
loginServer , authKey string ,
) error {
command := t . buildLoginCommand ( loginServer , authKey )
2023-09-10 10:00:12 +02:00
if _ , _ , err := t . Execute ( command , dockertestutil . ExecuteCommandTimeout ( dockerExecuteTimeout ) ) ; err != nil {
2023-08-29 08:33:33 +02:00
return fmt . Errorf (
"%s failed to join tailscale client (%s): %w" ,
t . hostname ,
strings . Join ( command , " " ) ,
err ,
)
2022-10-18 11:58:49 +02:00
}
2022-10-13 16:00:08 +02:00
return nil
}
2023-02-03 12:24:27 +01:00
// Up runs the login routine on the given Tailscale instance.
// This login mechanism uses web + command line flow for authentication.
2023-08-29 08:33:33 +02:00
func ( t * TailscaleInContainer ) LoginWithURL (
2022-11-03 16:56:19 +01:00
loginServer string ,
2025-01-26 22:20:11 +01:00
) ( loginURL * url . URL , err error ) {
2025-04-30 08:54:04 +03:00
command := t . buildLoginCommand ( loginServer , "" )
2025-02-23 14:10:25 -08:00
2025-01-26 22:20:11 +01:00
stdout , stderr , err := t . Execute ( command )
2022-11-13 21:25:19 +01:00
if errors . Is ( err , errTailscaleNotLoggedIn ) {
2022-11-03 16:56:19 +01:00
return nil , errTailscaleCannotUpWithoutAuthkey
}
2025-01-26 22:20:11 +01:00
defer func ( ) {
if err != nil {
log . Printf ( "join command: %q" , strings . Join ( command , " " ) )
}
} ( )
2025-02-25 09:16:07 -08:00
loginURL , err = util . ParseLoginURLFromCLILogin ( stdout + stderr )
2022-11-03 16:56:19 +01:00
if err != nil {
return nil , err
}
2022-11-13 21:25:19 +01:00
return loginURL , nil
2022-11-03 16:56:19 +01:00
}
2023-02-03 12:24:27 +01:00
// Logout runs the logout routine on the given Tailscale instance.
2022-12-21 22:29:52 +00:00
func ( t * TailscaleInContainer ) Logout ( ) error {
2025-01-26 22:20:11 +01:00
stdout , stderr , err := t . Execute ( [ ] string { "tailscale" , "logout" } )
2022-12-21 22:29:52 +00:00
if err != nil {
return err
}
2025-01-26 22:20:11 +01:00
stdout , stderr , _ = t . Execute ( [ ] string { "tailscale" , "status" } )
if ! strings . Contains ( stdout + stderr , "Logged out." ) {
return fmt . Errorf ( "failed to logout, stdout: %s, stderr: %s" , stdout , stderr )
}
2025-07-23 16:03:58 +02:00
return t . waitForBackendState ( "NeedsLogin" , integrationutil . PeerSyncTimeout ( ) )
2022-12-21 22:29:52 +00:00
}
2025-11-03 15:29:39 +01:00
// Restart restarts the Tailscale container using Docker API.
// This simulates a container restart (e.g., docker restart or Kubernetes pod restart).
// The container's entrypoint will re-execute, which typically includes running
// "tailscale up" with any auth keys stored in environment variables.
func ( t * TailscaleInContainer ) Restart ( ) error {
if t . container == nil {
2025-12-15 12:40:59 +00:00
return errContainerNotInitialized
2025-11-03 15:29:39 +01:00
}
// Use Docker API to restart the container
err := t . pool . Client . RestartContainer ( t . container . Container . ID , 30 )
if err != nil {
return fmt . Errorf ( "failed to restart container %s: %w" , t . hostname , err )
}
// Wait for the container to be back up and tailscaled to be ready
// We use exponential backoff to poll until we can successfully execute a command
_ , err = backoff . Retry ( context . Background ( ) , func ( ) ( struct { } , error ) {
// Try to execute a simple command to verify the container is responsive
_ , _ , err := t . Execute ( [ ] string { "tailscale" , "version" } , dockertestutil . ExecuteCommandTimeout ( 5 * time . Second ) )
if err != nil {
return struct { } { } , fmt . Errorf ( "container not ready: %w" , err )
}
2025-12-15 12:40:59 +00:00
2025-11-03 15:29:39 +01:00
return struct { } { } , nil
} , backoff . WithBackOff ( backoff . NewExponentialBackOff ( ) ) , backoff . WithMaxElapsedTime ( 30 * time . Second ) )
if err != nil {
return fmt . Errorf ( "timeout waiting for container %s to restart and become ready: %w" , t . hostname , err )
}
return nil
}
2023-12-09 18:09:24 +01:00
// Helper that runs `tailscale up` with no arguments.
func ( t * TailscaleInContainer ) Up ( ) error {
command := [ ] string {
"tailscale" ,
"up" ,
}
if _ , _ , err := t . Execute ( command , dockertestutil . ExecuteCommandTimeout ( dockerExecuteTimeout ) ) ; err != nil {
return fmt . Errorf (
"%s failed to bring tailscale client up (%s): %w" ,
t . hostname ,
strings . Join ( command , " " ) ,
err ,
)
}
return nil
}
// Helper that runs `tailscale down` with no arguments.
func ( t * TailscaleInContainer ) Down ( ) error {
command := [ ] string {
"tailscale" ,
"down" ,
}
if _ , _ , err := t . Execute ( command , dockertestutil . ExecuteCommandTimeout ( dockerExecuteTimeout ) ) ; err != nil {
return fmt . Errorf (
"%s failed to bring tailscale client down (%s): %w" ,
t . hostname ,
strings . Join ( command , " " ) ,
err ,
)
}
return nil
}
2023-02-03 12:24:27 +01:00
// IPs returns the netip.Addr of the Tailscale instance.
2022-10-13 16:00:08 +02:00
func ( t * TailscaleInContainer ) IPs ( ) ( [ ] netip . Addr , error ) {
2025-08-06 08:37:02 +02:00
if len ( t . ips ) != 0 {
2022-10-23 14:13:22 +02:00
return t . ips , nil
}
2025-10-23 17:57:41 +02:00
// Retry with exponential backoff to handle eventual consistency
ips , err := backoff . Retry ( context . Background ( ) , func ( ) ( [ ] netip . Addr , error ) {
command := [ ] string {
"tailscale" ,
"ip" ,
}
2022-10-13 16:00:08 +02:00
2025-10-23 17:57:41 +02:00
result , _ , err := t . Execute ( command )
if err != nil {
return nil , fmt . Errorf ( "%s failed to get IPs: %w" , t . hostname , err )
}
2022-10-13 16:00:08 +02:00
2025-10-23 17:57:41 +02:00
ips := make ( [ ] netip . Addr , 0 )
2025-12-15 12:40:59 +00:00
2025-10-23 17:57:41 +02:00
for address := range strings . SplitSeq ( result , "\n" ) {
address = strings . TrimSuffix ( address , "\n" )
if len ( address ) < 1 {
continue
}
2025-12-15 12:40:59 +00:00
2025-10-23 17:57:41 +02:00
ip , err := netip . ParseAddr ( address )
if err != nil {
return nil , fmt . Errorf ( "failed to parse IP %s: %w" , address , err )
}
2025-12-15 12:40:59 +00:00
2025-10-23 17:57:41 +02:00
ips = append ( ips , ip )
2022-10-13 16:00:08 +02:00
}
2025-10-23 17:57:41 +02:00
if len ( ips ) == 0 {
return nil , fmt . Errorf ( "no IPs returned yet for %s" , t . hostname )
2022-10-13 16:00:08 +02:00
}
2025-10-23 17:57:41 +02:00
return ips , nil
} , backoff . WithBackOff ( backoff . NewExponentialBackOff ( ) ) , backoff . WithMaxElapsedTime ( 10 * time . Second ) )
if err != nil {
return nil , fmt . Errorf ( "failed to get IPs for %s after retries: %w" , t . hostname , err )
2022-10-13 16:00:08 +02:00
}
return ips , nil
}
2025-03-21 11:49:32 +01:00
func ( t * TailscaleInContainer ) MustIPs ( ) [ ] netip . Addr {
ips , err := t . IPs ( )
if err != nil {
panic ( err )
}
2025-12-15 12:40:59 +00:00
2025-03-21 11:49:32 +01:00
return ips
}
2025-08-06 08:37:02 +02:00
// IPv4 returns the IPv4 address of the Tailscale instance.
func ( t * TailscaleInContainer ) IPv4 ( ) ( netip . Addr , error ) {
ips , err := t . IPs ( )
if err != nil {
return netip . Addr { } , err
}
for _ , ip := range ips {
if ip . Is4 ( ) {
return ip , nil
}
}
2025-10-23 17:57:41 +02:00
return netip . Addr { } , fmt . Errorf ( "no IPv4 address found for %s" , t . hostname )
2025-08-06 08:37:02 +02:00
}
2025-03-21 11:49:32 +01:00
func ( t * TailscaleInContainer ) MustIPv4 ( ) netip . Addr {
2025-10-23 17:57:41 +02:00
ip , err := t . IPv4 ( )
if err != nil {
panic ( err )
2025-03-21 11:49:32 +01:00
}
2025-12-15 12:40:59 +00:00
2025-10-23 17:57:41 +02:00
return ip
2025-03-21 11:49:32 +01:00
}
func ( t * TailscaleInContainer ) MustIPv6 ( ) netip . Addr {
for _ , ip := range t . MustIPs ( ) {
if ip . Is6 ( ) {
return ip
}
}
2025-12-15 12:40:59 +00:00
2025-03-21 11:49:32 +01:00
panic ( "no ipv6 found" )
}
2023-02-03 12:24:27 +01:00
// Status returns the ipnstate.Status of the Tailscale instance.
2024-02-23 10:59:24 +01:00
func ( t * TailscaleInContainer ) Status ( save ... bool ) ( * ipnstate . Status , error ) {
2022-10-13 16:00:08 +02:00
command := [ ] string {
2022-10-18 11:58:49 +02:00
"tailscale" ,
"status" ,
"--json" ,
2022-10-13 16:00:08 +02:00
}
2022-11-03 16:50:20 +01:00
result , _ , err := t . Execute ( command )
2022-10-13 16:00:08 +02:00
if err != nil {
2022-10-18 11:58:49 +02:00
return nil , fmt . Errorf ( "failed to execute tailscale status command: %w" , err )
2022-10-13 16:00:08 +02:00
}
2022-10-18 11:58:49 +02:00
var status ipnstate . Status
2025-12-15 12:40:59 +00:00
2022-10-18 11:58:49 +02:00
err = json . Unmarshal ( [ ] byte ( result ) , & status )
if err != nil {
return nil , fmt . Errorf ( "failed to unmarshal tailscale status: %w" , err )
2022-10-13 16:00:08 +02:00
}
2024-02-23 10:59:24 +01:00
err = os . WriteFile ( fmt . Sprintf ( "/tmp/control/%s_status.json" , t . hostname ) , [ ] byte ( result ) , 0 o755 )
if err != nil {
return nil , fmt . Errorf ( "status netmap to /tmp/control: %w" , err )
}
2022-10-18 11:58:49 +02:00
return & status , err
}
2025-04-30 08:54:04 +03:00
// MustStatus returns the ipnstate.Status of the Tailscale instance.
2025-02-26 07:22:55 -08:00
func ( t * TailscaleInContainer ) MustStatus ( ) * ipnstate . Status {
status , err := t . Status ( )
if err != nil {
panic ( err )
}
return status
}
2025-04-30 08:54:04 +03:00
// MustID returns the ID of the Tailscale instance.
func ( t * TailscaleInContainer ) MustID ( ) types . NodeID {
status , err := t . Status ( )
if err != nil {
panic ( err )
}
id , err := strconv . ParseUint ( string ( status . Self . ID ) , 10 , 64 )
if err != nil {
panic ( fmt . Sprintf ( "failed to parse ID: %s" , err ) )
}
return types . NodeID ( id )
}
2024-01-18 17:30:25 +01:00
// Netmap returns the current Netmap (netmap.NetworkMap) of the Tailscale instance.
2024-02-18 23:22:18 +01:00
// Only works with Tailscale 1.56 and newer.
// Panics if version is lower then minimum.
2024-02-23 10:59:24 +01:00
func ( t * TailscaleInContainer ) Netmap ( ) ( * netmap . NetworkMap , error ) {
if ! util . TailscaleVersionNewerOrEqual ( "1.56" , t . version ) {
2025-07-10 23:38:55 +02:00
panic ( "tsic.Netmap() called with unsupported version: " + t . version )
2024-02-23 10:59:24 +01:00
}
2024-02-18 23:22:18 +01:00
2024-02-23 10:59:24 +01:00
command := [ ] string {
"tailscale" ,
"debug" ,
"netmap" ,
}
2024-02-18 23:22:18 +01:00
2024-02-23 10:59:24 +01:00
result , stderr , err := t . Execute ( command )
if err != nil {
fmt . Printf ( "stderr: %s\n" , stderr )
return nil , fmt . Errorf ( "failed to execute tailscale debug netmap command: %w" , err )
}
2024-02-18 23:22:18 +01:00
2024-02-23 10:59:24 +01:00
var nm netmap . NetworkMap
2025-12-15 12:40:59 +00:00
2024-02-23 10:59:24 +01:00
err = json . Unmarshal ( [ ] byte ( result ) , & nm )
if err != nil {
return nil , fmt . Errorf ( "failed to unmarshal tailscale netmap: %w" , err )
}
2024-02-18 23:22:18 +01:00
2024-02-23 10:59:24 +01:00
err = os . WriteFile ( fmt . Sprintf ( "/tmp/control/%s_netmap.json" , t . hostname ) , [ ] byte ( result ) , 0 o755 )
if err != nil {
return nil , fmt . Errorf ( "saving netmap to /tmp/control: %w" , err )
}
return & nm , err
}
2024-02-18 23:22:18 +01:00
// Netmap returns the current Netmap (netmap.NetworkMap) of the Tailscale instance.
// This implementation is based on getting the netmap from `tailscale debug watch-ipn`
// as there seem to be some weirdness omitting endpoint and DERP info if we use
// Patch updates.
// This implementation works on all supported versions.
2024-02-23 10:59:24 +01:00
// func (t *TailscaleInContainer) Netmap() (*netmap.NetworkMap, error) {
// // watch-ipn will only give an update if something is happening,
// // since we send keep alives, the worst case for this should be
// // 1 minute, but set a slightly more conservative time.
// ctx, _ := context.WithTimeout(context.Background(), 3*time.Minute)
2024-01-18 17:30:25 +01:00
2024-02-23 10:59:24 +01:00
// notify, err := t.watchIPN(ctx)
// if err != nil {
// return nil, err
// }
2024-01-18 17:30:25 +01:00
2024-02-23 10:59:24 +01:00
// if notify.NetMap == nil {
// return nil, fmt.Errorf("no netmap present in ipn.Notify")
// }
2024-02-18 23:22:18 +01:00
2024-02-23 10:59:24 +01:00
// return notify.NetMap, nil
// }
2024-02-18 23:22:18 +01:00
// watchIPN watches `tailscale debug watch-ipn` for a ipn.Notify object until
// it gets one that has a netmap.NetworkMap.
func ( t * TailscaleInContainer ) watchIPN ( ctx context . Context ) ( * ipn . Notify , error ) {
pr , pw := io . Pipe ( )
type result struct {
notify * ipn . Notify
err error
2024-01-18 17:30:25 +01:00
}
2025-12-15 12:40:59 +00:00
2024-02-18 23:22:18 +01:00
resultChan := make ( chan result , 1 )
// There is no good way to kill the goroutine with watch-ipn,
// so make a nice func to send a kill command to issue when
// we are done.
killWatcher := func ( ) {
stdout , stderr , err := t . Execute ( [ ] string {
"/bin/sh" , "-c" , ` kill $(ps aux | grep "tailscale debug watch-ipn" | grep -v grep | awk ' { print $1}') || true ` ,
} )
if err != nil {
log . Printf ( "failed to kill tailscale watcher, \nstdout: %s\nstderr: %s\nerr: %s" , stdout , stderr , err )
}
}
go func ( ) {
_ , _ = t . container . Exec (
// Prior to 1.56, the initial "Connected." message was printed to stdout,
// filter out with grep.
[ ] string { "/bin/sh" , "-c" , ` tailscale debug watch-ipn | grep -v "Connected." ` } ,
dockertest . ExecOptions {
// The interesting output is sent to stdout, so ignore stderr.
StdOut : pw ,
// StdErr: pw,
} ,
)
} ( )
go func ( ) {
decoder := json . NewDecoder ( pr )
for decoder . More ( ) {
var notify ipn . Notify
2025-12-15 12:40:59 +00:00
err := decoder . Decode ( & notify )
if err != nil {
2024-02-18 23:22:18 +01:00
resultChan <- result { nil , fmt . Errorf ( "parse notify: %w" , err ) }
}
if notify . NetMap != nil {
resultChan <- result { & notify , nil }
}
}
} ( )
select {
case <- ctx . Done ( ) :
killWatcher ( )
return nil , ctx . Err ( )
case result := <- resultChan :
killWatcher ( )
2024-01-18 17:30:25 +01:00
2024-02-18 23:22:18 +01:00
if result . err != nil {
return nil , result . err
}
return result . notify , nil
}
2024-01-18 17:30:25 +01:00
}
2024-11-22 20:23:05 +08:00
func ( t * TailscaleInContainer ) DebugDERPRegion ( region string ) ( * ipnstate . DebugDERPRegionReport , error ) {
if ! util . TailscaleVersionNewerOrEqual ( "1.34" , t . version ) {
panic ( "tsic.DebugDERPRegion() called with unsupported version: " + t . version )
}
command := [ ] string {
"tailscale" ,
"debug" ,
"derp" ,
region ,
}
result , stderr , err := t . Execute ( command )
if err != nil {
fmt . Printf ( "stderr: %s\n" , stderr ) // nolint
return nil , fmt . Errorf ( "failed to execute tailscale debug derp command: %w" , err )
}
var report ipnstate . DebugDERPRegionReport
2025-12-15 12:40:59 +00:00
2024-11-22 20:23:05 +08:00
err = json . Unmarshal ( [ ] byte ( result ) , & report )
if err != nil {
return nil , fmt . Errorf ( "failed to unmarshal tailscale derp region report: %w" , err )
}
return & report , err
}
2024-02-09 07:26:41 +01:00
// Netcheck returns the current Netcheck Report (netcheck.Report) of the Tailscale instance.
func ( t * TailscaleInContainer ) Netcheck ( ) ( * netcheck . Report , error ) {
command := [ ] string {
"tailscale" ,
"netcheck" ,
"--format=json" ,
}
result , stderr , err := t . Execute ( command )
if err != nil {
fmt . Printf ( "stderr: %s\n" , stderr )
return nil , fmt . Errorf ( "failed to execute tailscale debug netcheck command: %w" , err )
}
var nm netcheck . Report
2025-12-15 12:40:59 +00:00
2024-02-09 07:26:41 +01:00
err = json . Unmarshal ( [ ] byte ( result ) , & nm )
if err != nil {
return nil , fmt . Errorf ( "failed to unmarshal tailscale netcheck: %w" , err )
}
return & nm , err
}
2023-02-03 12:24:27 +01:00
// FQDN returns the FQDN as a string of the Tailscale instance.
2022-10-21 17:44:40 +02:00
func ( t * TailscaleInContainer ) FQDN ( ) ( string , error ) {
2022-10-23 14:13:22 +02:00
if t . fqdn != "" {
return t . fqdn , nil
}
2025-10-23 17:57:41 +02:00
// Retry with exponential backoff to handle eventual consistency
fqdn , err := backoff . Retry ( context . Background ( ) , func ( ) ( string , error ) {
status , err := t . Status ( )
if err != nil {
return "" , fmt . Errorf ( "failed to get status: %w" , err )
}
if status . Self . DNSName == "" {
2025-12-15 12:40:59 +00:00
return "" , errFQDNNotYetAvailable
2025-10-23 17:57:41 +02:00
}
return status . Self . DNSName , nil
} , backoff . WithBackOff ( backoff . NewExponentialBackOff ( ) ) , backoff . WithMaxElapsedTime ( 10 * time . Second ) )
2022-10-21 17:44:40 +02:00
if err != nil {
2025-10-23 17:57:41 +02:00
return "" , fmt . Errorf ( "failed to get FQDN for %s after retries: %w" , t . hostname , err )
2022-10-21 17:44:40 +02:00
}
2025-10-23 17:57:41 +02:00
return fqdn , nil
}
// MustFQDN returns the FQDN as a string of the Tailscale instance, panicking on error.
func ( t * TailscaleInContainer ) MustFQDN ( ) string {
fqdn , err := t . FQDN ( )
if err != nil {
panic ( err )
}
2025-12-15 12:40:59 +00:00
2025-10-23 17:57:41 +02:00
return fqdn
2022-10-21 17:44:40 +02:00
}
2024-05-24 09:15:34 +01:00
// FailingPeersAsString returns a formatted-ish multi-line-string of peers in the client
// and a bool indicating if the clients online count and peer count is equal.
func ( t * TailscaleInContainer ) FailingPeersAsString ( ) ( string , bool , error ) {
2023-12-09 18:09:24 +01:00
status , err := t . Status ( )
if err != nil {
2024-05-24 09:15:34 +01:00
return "" , false , fmt . Errorf ( "failed to get FQDN: %w" , err )
2023-12-09 18:09:24 +01:00
}
2024-05-24 09:15:34 +01:00
var b strings . Builder
fmt . Fprintf ( & b , "Peers of %s\n" , t . hostname )
fmt . Fprint ( & b , "Hostname\tOnline\tLastSeen\n" )
2023-12-09 18:09:24 +01:00
peerCount := len ( status . Peers ( ) )
onlineCount := 0
for _ , peerKey := range status . Peers ( ) {
peer := status . Peer [ peerKey ]
if peer . Online {
onlineCount ++
}
2024-05-24 09:15:34 +01:00
fmt . Fprintf ( & b , "%s\t%t\t%s\n" , peer . HostName , peer . Online , peer . LastSeen )
2023-12-09 18:09:24 +01:00
}
2024-05-24 09:15:34 +01:00
fmt . Fprintf ( & b , "Peer Count: %d, Online Count: %d\n\n" , peerCount , onlineCount )
2023-12-09 18:09:24 +01:00
2024-05-24 09:15:34 +01:00
return b . String ( ) , peerCount == onlineCount , nil
2023-12-09 18:09:24 +01:00
}
2023-08-29 08:33:33 +02:00
// WaitForNeedsLogin blocks until the Tailscale (tailscaled) instance has
// started and needs to be logged into.
2025-07-23 16:03:58 +02:00
func ( t * TailscaleInContainer ) WaitForNeedsLogin ( timeout time . Duration ) error {
return t . waitForBackendState ( "NeedsLogin" , timeout )
2023-08-29 08:33:33 +02:00
}
// WaitForRunning blocks until the Tailscale (tailscaled) instance is logged in
// and ready to be used.
2025-07-23 16:03:58 +02:00
func ( t * TailscaleInContainer ) WaitForRunning ( timeout time . Duration ) error {
return t . waitForBackendState ( "Running" , timeout )
2025-01-26 22:20:11 +01:00
}
2025-07-23 16:03:58 +02:00
func ( t * TailscaleInContainer ) waitForBackendState ( state string , timeout time . Duration ) error {
ticker := time . NewTicker ( integrationutil . PeerSyncRetryInterval ( ) )
defer ticker . Stop ( )
ctx , cancel := context . WithTimeout ( context . Background ( ) , timeout )
defer cancel ( )
for {
select {
case <- ctx . Done ( ) :
return fmt . Errorf ( "timeout waiting for backend state %s on %s after %v" , state , t . hostname , timeout )
case <- ticker . C :
status , err := t . Status ( )
if err != nil {
continue // Keep retrying on status errors
}
2023-08-29 08:33:33 +02:00
2025-07-23 16:03:58 +02:00
// ipnstate.Status.CurrentTailnet was added in Tailscale 1.22.0
// https://github.com/tailscale/tailscale/pull/3865
//
// Before that, we can check the BackendState to see if the
// tailscaled daemon is connected to the control system.
if status . BackendState == state {
return nil
}
2023-04-12 09:25:51 +02:00
}
2025-07-23 16:03:58 +02:00
}
2022-11-13 13:06:53 +01:00
}
2023-02-03 12:24:27 +01:00
// WaitForPeers blocks until N number of peers is present in the
2023-12-09 18:09:24 +01:00
// Peer list of the Tailscale instance and is reporting Online.
2025-07-23 16:03:58 +02:00
//
// The method verifies that each peer:
// - Has the expected peer count
// - All peers are Online
// - All peers have a hostname
// - All peers have a DERP relay assigned
//
// Uses multierr to collect all validation errors.
func ( t * TailscaleInContainer ) WaitForPeers ( expected int , timeout , retryInterval time . Duration ) error {
ticker := time . NewTicker ( retryInterval )
defer ticker . Stop ( )
ctx , cancel := context . WithTimeout ( context . Background ( ) , timeout )
defer cancel ( )
var lastErrs [ ] error
2025-12-15 12:40:59 +00:00
2025-07-23 16:03:58 +02:00
for {
select {
case <- ctx . Done ( ) :
if len ( lastErrs ) > 0 {
return fmt . Errorf ( "timeout waiting for %d peers on %s after %v, errors: %w" , expected , t . hostname , timeout , multierr . New ( lastErrs ... ) )
}
2025-12-15 12:40:59 +00:00
2025-07-23 16:03:58 +02:00
return fmt . Errorf ( "timeout waiting for %d peers on %s after %v" , expected , t . hostname , timeout )
case <- ticker . C :
status , err := t . Status ( )
if err != nil {
lastErrs = [ ] error { errTailscaleStatus ( t . hostname , err ) }
continue // Keep retrying on status errors
}
if peers := status . Peers ( ) ; len ( peers ) != expected {
lastErrs = [ ] error { fmt . Errorf (
"%s err: %w expected %d, got %d" ,
t . hostname ,
errTailscaleWrongPeerCount ,
expected ,
len ( peers ) ,
) }
2025-08-06 08:37:02 +02:00
2025-07-23 16:03:58 +02:00
continue
}
2022-10-18 11:58:49 +02:00
2024-02-09 07:26:41 +01:00
// Verify that the peers of a given node is Online
// has a hostname and a DERP relay.
2025-07-23 16:03:58 +02:00
var peerErrors [ ] error
2025-12-15 12:40:59 +00:00
2025-07-23 16:03:58 +02:00
for _ , peerKey := range status . Peers ( ) {
2023-12-09 18:09:24 +01:00
peer := status . Peer [ peerKey ]
if ! peer . Online {
2025-07-23 16:03:58 +02:00
peerErrors = append ( peerErrors , fmt . Errorf ( "[%s] peer count correct, but %s is not online" , t . hostname , peer . HostName ) )
2023-12-09 18:09:24 +01:00
}
2024-02-09 07:26:41 +01:00
if peer . HostName == "" {
2025-07-23 16:03:58 +02:00
peerErrors = append ( peerErrors , fmt . Errorf ( "[%s] peer count correct, but %s does not have a Hostname" , t . hostname , peer . HostName ) )
2024-02-09 07:26:41 +01:00
}
if peer . Relay == "" {
2025-07-23 16:03:58 +02:00
peerErrors = append ( peerErrors , fmt . Errorf ( "[%s] peer count correct, but %s does not have a DERP" , t . hostname , peer . HostName ) )
2024-02-09 07:26:41 +01:00
}
2023-12-09 18:09:24 +01:00
}
2022-10-18 11:58:49 +02:00
2025-07-23 16:03:58 +02:00
if len ( peerErrors ) > 0 {
lastErrs = peerErrors
continue
}
return nil
}
}
2022-10-18 11:58:49 +02:00
}
2023-02-02 16:05:52 +01:00
type (
2023-04-13 21:09:09 +00:00
// PingOption represent optional settings that can be given
2023-02-03 12:24:27 +01:00
// to ping another host.
2023-02-02 16:05:52 +01:00
PingOption = func ( args * pingArgs )
2023-02-03 12:24:27 +01:00
pingArgs struct {
2023-02-02 16:05:52 +01:00
timeout time . Duration
count int
direct bool
}
)
2023-02-03 12:24:27 +01:00
// WithPingTimeout sets the timeout for the ping command.
2023-02-02 16:05:52 +01:00
func WithPingTimeout ( timeout time . Duration ) PingOption {
return func ( args * pingArgs ) {
args . timeout = timeout
}
}
2023-02-03 12:24:27 +01:00
// WithPingCount sets the count of pings to attempt.
2023-02-02 16:05:52 +01:00
func WithPingCount ( count int ) PingOption {
return func ( args * pingArgs ) {
args . count = count
}
}
2023-02-03 12:24:27 +01:00
// WithPingUntilDirect decides if the ping should only succeed
// if a direct connection is established or if successful
// DERP ping is sufficient.
2023-02-02 16:05:52 +01:00
func WithPingUntilDirect ( direct bool ) PingOption {
return func ( args * pingArgs ) {
args . direct = direct
}
}
2023-02-03 12:24:27 +01:00
// Ping executes the Tailscale ping command and pings a hostname
// or IP. It accepts a series of PingOption.
2022-10-18 12:09:10 +02:00
// TODO(kradalby): Make multiping, go routine magic.
2023-02-02 16:05:52 +01:00
func ( t * TailscaleInContainer ) Ping ( hostnameOrIP string , opts ... PingOption ) error {
args := pingArgs {
2023-08-09 22:56:21 +02:00
timeout : defaultPingTimeout ,
2023-02-02 16:05:52 +01:00
count : defaultPingCount ,
direct : true ,
}
2022-10-18 11:58:49 +02:00
2023-02-02 16:05:52 +01:00
for _ , opt := range opts {
opt ( & args )
}
command := [ ] string {
"tailscale" , "ping" ,
fmt . Sprintf ( "--timeout=%s" , args . timeout ) ,
fmt . Sprintf ( "--c=%d" , args . count ) ,
2025-07-10 23:38:55 +02:00
"--until-direct=" + strconv . FormatBool ( args . direct ) ,
2023-02-02 16:05:52 +01:00
}
command = append ( command , hostnameOrIP )
2023-07-26 17:51:33 +02:00
result , _ , err := t . Execute (
command ,
dockertestutil . ExecuteCommandTimeout (
time . Duration ( int64 ( args . timeout ) * int64 ( args . count ) ) ,
) ,
)
if err != nil {
2025-03-21 11:49:32 +01:00
log . Printf ( "command: %v" , command )
2023-07-26 17:51:33 +02:00
log . Printf (
"failed to run ping command from %s to %s, err: %s" ,
t . Hostname ( ) ,
hostnameOrIP ,
err ,
2023-04-13 21:09:09 +00:00
)
2023-07-26 17:51:33 +02:00
return err
}
2023-04-13 21:09:09 +00:00
2023-07-26 17:51:33 +02:00
if strings . Contains ( result , "is local" ) {
return nil
}
2023-04-13 21:09:09 +00:00
2023-07-26 17:51:33 +02:00
if ! strings . Contains ( result , "pong" ) {
return errTailscalePingFailed
}
2023-04-13 21:09:09 +00:00
2023-07-26 17:51:33 +02:00
if ! args . direct {
if strings . Contains ( result , "via DERP" ) {
return nil
} else {
return errTailscalePingNotDERP
2023-04-13 21:09:09 +00:00
}
2023-07-26 17:51:33 +02:00
}
2023-04-13 21:09:09 +00:00
2023-07-26 17:51:33 +02:00
return nil
2023-04-13 21:09:09 +00:00
}
2023-03-20 08:52:52 +01:00
type (
// CurlOption repreent optional settings that can be given
// to curl another host.
CurlOption = func ( args * curlArgs )
curlArgs struct {
connectionTimeout time . Duration
maxTime time . Duration
retry int
retryDelay time . Duration
retryMaxTime time . Duration
}
)
// WithCurlConnectionTimeout sets the timeout for each connection started
// by curl.
func WithCurlConnectionTimeout ( timeout time . Duration ) CurlOption {
return func ( args * curlArgs ) {
args . connectionTimeout = timeout
}
}
// WithCurlMaxTime sets the max time for a transfer for each connection started
// by curl.
func WithCurlMaxTime ( t time . Duration ) CurlOption {
return func ( args * curlArgs ) {
args . maxTime = t
}
}
// WithCurlRetry sets the number of retries a connection is attempted by curl.
func WithCurlRetry ( ret int ) CurlOption {
return func ( args * curlArgs ) {
args . retry = ret
}
}
const (
2025-08-06 08:37:02 +02:00
defaultConnectionTimeout = 1 * time . Second
defaultMaxTime = 3 * time . Second
defaultRetry = 3
defaultRetryDelay = 200 * time . Millisecond
defaultRetryMaxTime = 5 * time . Second
2023-03-20 08:52:52 +01:00
)
// Curl executes the Tailscale curl command and curls a hostname
// or IP. It accepts a series of CurlOption.
func ( t * TailscaleInContainer ) Curl ( url string , opts ... CurlOption ) ( string , error ) {
args := curlArgs {
connectionTimeout : defaultConnectionTimeout ,
maxTime : defaultMaxTime ,
retry : defaultRetry ,
retryDelay : defaultRetryDelay ,
retryMaxTime : defaultRetryMaxTime ,
}
for _ , opt := range opts {
opt ( & args )
}
command := [ ] string {
"curl" ,
"--silent" ,
2025-07-10 23:38:55 +02:00
"--connect-timeout" , strconv . Itoa ( int ( args . connectionTimeout . Seconds ( ) ) ) ,
"--max-time" , strconv . Itoa ( int ( args . maxTime . Seconds ( ) ) ) ,
"--retry" , strconv . Itoa ( args . retry ) ,
"--retry-delay" , strconv . Itoa ( int ( args . retryDelay . Seconds ( ) ) ) ,
"--retry-max-time" , strconv . Itoa ( int ( args . retryMaxTime . Seconds ( ) ) ) ,
2023-03-20 08:52:52 +01:00
url ,
}
var result string
2025-12-15 12:40:59 +00:00
2023-07-26 17:51:33 +02:00
result , _ , err := t . Execute ( command )
if err != nil {
log . Printf (
"failed to run curl command from %s to %s, err: %s" ,
t . Hostname ( ) ,
url ,
err ,
)
2023-03-20 08:52:52 +01:00
2023-07-26 17:51:33 +02:00
return result , err
}
2023-03-20 08:52:52 +01:00
2023-07-26 17:51:33 +02:00
return result , nil
2023-03-20 08:52:52 +01:00
}
2025-08-06 08:37:02 +02:00
// CurlFailFast executes the Tailscale curl command with aggressive timeouts
// optimized for testing expected connection failures. It uses minimal timeouts
// to quickly detect blocked connections without waiting for multiple retries.
func ( t * TailscaleInContainer ) CurlFailFast ( url string ) ( string , error ) {
// Use aggressive timeouts for fast failure detection
return t . Curl ( url ,
WithCurlConnectionTimeout ( 1 * time . Second ) ,
WithCurlMaxTime ( 2 * time . Second ) ,
WithCurlRetry ( 1 ) )
}
2025-03-21 11:49:32 +01:00
func ( t * TailscaleInContainer ) Traceroute ( ip netip . Addr ) ( util . Traceroute , error ) {
command := [ ] string {
"traceroute" ,
ip . String ( ) ,
}
var result util . Traceroute
2025-12-15 12:40:59 +00:00
2025-03-21 11:49:32 +01:00
stdout , stderr , err := t . Execute ( command )
if err != nil {
return result , err
}
result , err = util . ParseTraceroute ( stdout + stderr )
if err != nil {
return result , err
}
return result , nil
}
2023-02-03 12:24:27 +01:00
// WriteFile save file inside the Tailscale container.
2022-11-06 20:22:21 +01:00
func ( t * TailscaleInContainer ) WriteFile ( path string , data [ ] byte ) error {
return integrationutil . WriteFileToContainer ( t . pool , t . container , path , data )
}
2023-12-09 18:09:24 +01:00
// SaveLog saves the current stdout log of the container to a path
// on the host system.
2024-12-09 17:15:38 +01:00
func ( t * TailscaleInContainer ) SaveLog ( path string ) ( string , string , error ) {
2024-09-11 12:00:32 +02:00
// TODO(kradalby): Assert if tailscale logs contains panics.
2024-09-21 12:05:36 +02:00
// NOTE(enoperm): `t.WriteLog | countMatchingLines`
// is probably most of what is for that,
// but I'd rather not change the behaviour here,
// as it may affect all the other tests
// I have not otherwise touched.
2024-12-09 17:15:38 +01:00
return dockertestutil . SaveLog ( t . pool , t . container , path )
2023-12-09 18:09:24 +01:00
}
2024-08-19 11:41:05 +02:00
2024-09-21 12:05:36 +02:00
// WriteLogs writes the current stdout/stderr log of the container to
// the given io.Writers.
func ( t * TailscaleInContainer ) WriteLogs ( stdout , stderr io . Writer ) error {
return dockertestutil . WriteLog ( t . pool , t . container , stdout , stderr )
}
2024-08-19 11:41:05 +02:00
// ReadFile reads a file from the Tailscale container.
// It returns the content of the file as a byte slice.
func ( t * TailscaleInContainer ) ReadFile ( path string ) ( [ ] byte , error ) {
tarBytes , err := integrationutil . FetchPathFromContainer ( t . pool , t . container , path )
if err != nil {
return nil , fmt . Errorf ( "reading file from container: %w" , err )
}
var out bytes . Buffer
2025-12-15 12:40:59 +00:00
2024-08-19 11:41:05 +02:00
tr := tar . NewReader ( bytes . NewReader ( tarBytes ) )
for {
hdr , err := tr . Next ( )
if err == io . EOF {
break // End of archive
}
2025-12-15 12:40:59 +00:00
2024-08-19 11:41:05 +02:00
if err != nil {
return nil , fmt . Errorf ( "reading tar header: %w" , err )
}
if ! strings . Contains ( path , hdr . Name ) {
return nil , fmt . Errorf ( "file not found in tar archive, looking for: %s, header was: %s" , path , hdr . Name )
}
if _ , err := io . Copy ( & out , tr ) ; err != nil {
return nil , fmt . Errorf ( "copying file to buffer: %w" , err )
}
// Only support reading the first tile
break
}
if out . Len ( ) == 0 {
2025-07-10 23:38:55 +02:00
return nil , errors . New ( "file is empty" )
2024-08-19 11:41:05 +02:00
}
return out . Bytes ( ) , nil
}
2025-06-18 15:24:53 +08:00
func ( t * TailscaleInContainer ) GetNodePrivateKey ( ) ( * key . NodePrivate , error ) {
state , err := t . ReadFile ( paths . DefaultTailscaledStateFile ( ) )
if err != nil {
return nil , fmt . Errorf ( "failed to read state file: %w" , err )
}
2025-12-15 12:40:59 +00:00
2025-06-18 15:24:53 +08:00
store := & mem . Store { }
if err = store . LoadFromJSON ( state ) ; err != nil {
return nil , fmt . Errorf ( "failed to unmarshal state file: %w" , err )
}
currentProfileKey , err := store . ReadState ( ipn . CurrentProfileStateKey )
if err != nil {
return nil , fmt . Errorf ( "failed to read current profile state key: %w" , err )
}
2025-12-15 12:40:59 +00:00
2025-06-18 15:24:53 +08:00
currentProfile , err := store . ReadState ( ipn . StateKey ( currentProfileKey ) )
if err != nil {
return nil , fmt . Errorf ( "failed to read current profile state: %w" , err )
}
p := & ipn . Prefs { }
if err = json . Unmarshal ( currentProfile , & p ) ; err != nil {
return nil , fmt . Errorf ( "failed to unmarshal current profile state: %w" , err )
}
2025-07-10 23:38:55 +02:00
2025-06-18 15:24:53 +08:00
return & p . Persist . PrivateNodeKey , nil
}
2025-10-23 17:57:41 +02:00
// PacketFilter returns the current packet filter rules from the client's network map.
// This is useful for verifying that policy changes have propagated to the client.
func ( t * TailscaleInContainer ) PacketFilter ( ) ( [ ] filter . Match , error ) {
if ! util . TailscaleVersionNewerOrEqual ( "1.56" , t . version ) {
return nil , fmt . Errorf ( "tsic.PacketFilter() requires Tailscale 1.56+, current version: %s" , t . version )
}
nm , err := t . Netmap ( )
if err != nil {
return nil , fmt . Errorf ( "failed to get netmap: %w" , err )
}
return nm . PacketFilter , nil
}