feat: support tailscale exit nodes

This commit is contained in:
Peter Steinberger 2026-05-06 09:47:42 +01:00
parent 9680656ec9
commit 671d362e29
No known key found for this signature in database
24 changed files with 284 additions and 70 deletions

View File

@ -199,13 +199,16 @@ tailscale:
- tag:crabbox
hostnameTemplate: crabbox-{slug}
authKeyEnv: CRABBOX_TAILSCALE_AUTH_KEY
exitNode: mac-studio.example.ts.net
exitNodeAllowLanAccess: true
```
Tailscale is a network plane, not a provider. `--tailscale` joins new managed
Linux leases to the tailnet; `--network auto|tailscale|public` chooses how SSH
and VNC tunnel commands resolve the host. Brokered mode uses Worker OAuth
secrets to mint one-off keys; direct-provider mode reads the auth key from the
configured env var. See [Tailscale](docs/features/tailscale.md).
configured env var. `exitNode` is opt-in per lease for routing outbound internet
through an approved tailnet exit node. See [Tailscale](docs/features/tailscale.md).
Forwarded environment is intentionally narrow: `NODE_OPTIONS` and `CI`. Do not pass secrets as command-line arguments. Full env-var reference and per-command flags are in [docs/cli.md](docs/cli.md) and [docs/commands/](docs/commands/README.md).

View File

@ -267,6 +267,8 @@ Flags:
--tailscale-tags <csv> Tailscale tags for new managed leases
--tailscale-hostname-template <template>
--tailscale-auth-key-env <env-var>
--tailscale-exit-node <name-or-100.x>
--tailscale-exit-node-allow-lan-access
--network auto|tailscale|public
--no-sync run without syncing
--sync-only sync and exit
@ -559,6 +561,8 @@ CRABBOX_TAILSCALE_TAGS
CRABBOX_TAILSCALE_HOSTNAME_TEMPLATE
CRABBOX_TAILSCALE_AUTH_KEY_ENV
CRABBOX_TAILSCALE_AUTH_KEY direct-provider only, via auth-key env
CRABBOX_TAILSCALE_EXIT_NODE
CRABBOX_TAILSCALE_EXIT_NODE_ALLOW_LAN_ACCESS
```
Provider/deploy variables live outside normal CLI operation:

View File

@ -36,6 +36,8 @@ tailscale:
- tag:crabbox
hostnameTemplate: crabbox-{slug}
authKeyEnv: CRABBOX_TAILSCALE_AUTH_KEY
exitNode: mac-studio.example.ts.net
exitNodeAllowLanAccess: true
capacity:
market: spot
strategy: most-available
@ -72,3 +74,7 @@ env:
Brokered `--tailscale` leases use Worker-minted one-off auth keys. Direct
provider leases read a local one-off key from `tailscale.authKeyEnv`; do not
store that key in repo config.
`tailscale.exitNode` routes lease egress through an approved tailnet exit node.
`tailscale.exitNodeAllowLanAccess` keeps LAN access available while using that
exit node.

View File

@ -109,6 +109,8 @@ Flags:
--tailscale-tags <comma-separated tags>
--tailscale-hostname-template <template>
--tailscale-auth-key-env <env-var>
--tailscale-exit-node <name-or-100.x>
--tailscale-exit-node-allow-lan-access
--network auto|tailscale|public
--keep
--no-sync

View File

@ -89,6 +89,8 @@ Flags:
--tailscale-tags <comma-separated tags>
--tailscale-hostname-template <template>
--tailscale-auth-key-env <env-var>
--tailscale-exit-node <name-or-100.x>
--tailscale-exit-node-allow-lan-access
--network auto|tailscale|public
--keep
--actions-runner

View File

@ -58,6 +58,8 @@ tailscale:
- tag:crabbox
hostnameTemplate: crabbox-{slug}
authKeyEnv: CRABBOX_TAILSCALE_AUTH_KEY
exitNode: mac-studio.example.ts.net
exitNodeAllowLanAccess: true
```
Environment overrides:
@ -68,6 +70,8 @@ CRABBOX_NETWORK=auto|tailscale|public
CRABBOX_TAILSCALE_TAGS=tag:crabbox,tag:ci
CRABBOX_TAILSCALE_HOSTNAME_TEMPLATE=crabbox-{slug}
CRABBOX_TAILSCALE_AUTH_KEY=<direct-provider only>
CRABBOX_TAILSCALE_EXIT_NODE=mac-studio.example.ts.net
CRABBOX_TAILSCALE_EXIT_NODE_ALLOW_LAN_ACCESS=1
```
`tailscale.enabled` and `--tailscale` request tailnet join for newly created
@ -78,6 +82,11 @@ and `{provider}`.
Direct-provider mode reads the one-off auth key from `tailscale.authKeyEnv`.
Brokered mode does not require a local Tailscale key.
`tailscale.exitNode` asks the lease to route outbound internet through a
tailnet exit node after it joins Tailscale. Use a MagicDNS name or 100.x address
for an approved exit node. `tailscale.exitNodeAllowLanAccess` maps to
Tailscale's LAN-access flag and requires `tailscale.exitNode`.
## Brokered Mode
The Worker mints a fresh auth key per requested lease using Tailscale OAuth.
@ -93,8 +102,8 @@ CRABBOX_TAILSCALE_ENABLED set 0 to disable
Flow:
1. The CLI sends `tailscale`, `tailscaleTags`, and `tailscaleHostname` in
`CreateLease`.
1. The CLI sends `tailscale`, `tailscaleTags`, `tailscaleHostname`, and optional
exit-node settings in `CreateLease`.
2. The Worker validates requested tags against `CRABBOX_TAILSCALE_TAGS`.
3. The Worker uses OAuth to mint a one-off, ephemeral, pre-approved, tagged auth
key.
@ -108,6 +117,19 @@ The auth key is never stored in lease records, provider labels, run logs, or
local config. User-data can still contain the short-lived key at the provider,
so use one-off ephemeral keys and avoid long-lived reusable keys.
## Exit Nodes
Exit-node egress is opt-in per lease:
```sh
crabbox warmup --tailscale --tailscale-exit-node mac-studio.example.ts.net --tailscale-exit-node-allow-lan-access
crabbox run --tailscale --tailscale-exit-node 100.100.100.100 -- curl -4 https://ifconfig.me
```
The exit node must already advertise exit-node capability and be approved in
Tailscale admin. ACLs/grants must allow the lease's tags, such as
`tag:crabbox`, to access `autogroup:internet` through exit nodes.
## VNC And SSH
Crabbox continues to use OpenSSH and per-lease SSH keys. Tailscale SSH is not

View File

@ -601,6 +601,18 @@ func cloudInitTailscaleBootstrap(cfg Config) string {
sshUserGroup := shellQuote(sshUser)
sshUserChown := shellQuote(sshUser + ":" + sshUser)
tags := strings.Join(cfg.Tailscale.Tags, ",")
tailscaleUpArgs := []string{
"--auth-key=\"$TS_AUTHKEY\"",
"--hostname=" + shellQuote(hostname),
"--advertise-tags=" + shellQuote(tags),
}
exitNode := strings.TrimSpace(cfg.Tailscale.ExitNode)
if exitNode != "" {
tailscaleUpArgs = append(tailscaleUpArgs, "--exit-node="+shellQuote(exitNode))
if cfg.Tailscale.ExitNodeAllowLANAccess {
tailscaleUpArgs = append(tailscaleUpArgs, "--exit-node-allow-lan-access")
}
}
if authKey == "" {
return ` echo "tailscale requested but no auth key was injected" >&2
exit 1`
@ -610,7 +622,7 @@ func cloudInitTailscaleBootstrap(cfg Config) string {
install -d -m 0750 -o ` + sshUserOwner + ` -g ` + sshUserGroup + ` /var/lib/crabbox
set +x
TS_AUTHKEY=` + shellQuote(authKey) + `
tailscale up --auth-key="$TS_AUTHKEY" --hostname=` + shellQuote(hostname) + ` --advertise-tags=` + shellQuote(tags) + `
tailscale up ` + strings.Join(tailscaleUpArgs, " ") + `
unset TS_AUTHKEY
set -x
ts_ip=""
@ -622,6 +634,10 @@ func cloudInitTailscaleBootstrap(cfg Config) string {
test -n "$ts_ip"
printf '%s\n' "$ts_ip" > /var/lib/crabbox/tailscale-ipv4
printf '%s\n' ` + shellQuote(hostname) + ` > /var/lib/crabbox/tailscale-hostname
if [ -n ` + shellQuote(exitNode) + ` ]; then
printf '%s\n' ` + shellQuote(exitNode) + ` > /var/lib/crabbox/tailscale-exit-node
printf '%s\n' ` + shellQuote(fmt.Sprint(cfg.Tailscale.ExitNodeAllowLANAccess)) + ` > /var/lib/crabbox/tailscale-exit-node-allow-lan-access
fi
if tailscale status --json >/var/lib/crabbox/tailscale-status.json 2>/dev/null; then
jq -r '.Self.DNSName // empty' /var/lib/crabbox/tailscale-status.json > /var/lib/crabbox/tailscale-fqdn || true
fi

View File

@ -144,12 +144,16 @@ func TestCloudInitTailscaleProfile(t *testing.T) {
cfg.Tailscale.AuthKey = "tskey-secret"
cfg.Tailscale.Hostname = "crabbox-blue-lobster"
cfg.Tailscale.Tags = []string{"tag:crabbox"}
cfg.Tailscale.ExitNode = "mac-studio.tailnet.ts.net"
cfg.Tailscale.ExitNodeAllowLANAccess = true
got := cloudInit(cfg, "ssh-ed25519 test")
for _, want := range []string{
"https://tailscale.com/install.sh",
"install -d -m 0750 -o 'runner' -g 'runner' /var/lib/crabbox",
"tailscale up --auth-key=\"$TS_AUTHKEY\" --hostname='crabbox-blue-lobster' --advertise-tags='tag:crabbox'",
"tailscale up --auth-key=\"$TS_AUTHKEY\" --hostname='crabbox-blue-lobster' --advertise-tags='tag:crabbox' --exit-node='mac-studio.tailnet.ts.net' --exit-node-allow-lan-access",
"printf '%s\\n' 'crabbox-blue-lobster' > /var/lib/crabbox/tailscale-hostname",
"printf '%s\\n' 'mac-studio.tailnet.ts.net' > /var/lib/crabbox/tailscale-exit-node",
"printf '%s\\n' 'true' > /var/lib/crabbox/tailscale-exit-node-allow-lan-access",
"chown 'runner:runner' /var/lib/crabbox/tailscale-* || true",
"test -s /var/lib/crabbox/tailscale-ipv4",
"grep -Eq '^100\\.' /var/lib/crabbox/tailscale-ipv4",

View File

@ -411,11 +411,13 @@ type fileIsloConfig struct {
}
type fileTailscaleConfig struct {
Enabled *bool `yaml:"enabled,omitempty"`
Network string `yaml:"network,omitempty"`
Tags []string `yaml:"tags,omitempty"`
HostnameTemplate string `yaml:"hostnameTemplate,omitempty"`
AuthKeyEnv string `yaml:"authKeyEnv,omitempty"`
Enabled *bool `yaml:"enabled,omitempty"`
Network string `yaml:"network,omitempty"`
Tags []string `yaml:"tags,omitempty"`
HostnameTemplate string `yaml:"hostnameTemplate,omitempty"`
AuthKeyEnv string `yaml:"authKeyEnv,omitempty"`
ExitNode string `yaml:"exitNode,omitempty"`
ExitNodeAllowLANAccess *bool `yaml:"exitNodeAllowLanAccess,omitempty"`
}
type fileStaticConfig struct {
@ -834,6 +836,12 @@ func applyFileConfig(cfg *Config, file fileConfig) {
if file.Tailscale.AuthKeyEnv != "" {
cfg.Tailscale.AuthKeyEnv = file.Tailscale.AuthKeyEnv
}
if file.Tailscale.ExitNode != "" {
cfg.Tailscale.ExitNode = strings.TrimSpace(file.Tailscale.ExitNode)
}
if file.Tailscale.ExitNodeAllowLANAccess != nil {
cfg.Tailscale.ExitNodeAllowLANAccess = *file.Tailscale.ExitNodeAllowLANAccess
}
}
if file.Static != nil {
if file.Static.ID != "" {
@ -982,6 +990,10 @@ func applyEnv(cfg *Config) {
}
cfg.Tailscale.HostnameTemplate = getenv("CRABBOX_TAILSCALE_HOSTNAME_TEMPLATE", cfg.Tailscale.HostnameTemplate)
cfg.Tailscale.AuthKeyEnv = getenv("CRABBOX_TAILSCALE_AUTH_KEY_ENV", cfg.Tailscale.AuthKeyEnv)
cfg.Tailscale.ExitNode = getenv("CRABBOX_TAILSCALE_EXIT_NODE", cfg.Tailscale.ExitNode)
if value, ok := getenvBool("CRABBOX_TAILSCALE_EXIT_NODE_ALLOW_LAN_ACCESS"); ok {
cfg.Tailscale.ExitNodeAllowLANAccess = value
}
if cfg.Tailscale.AuthKeyEnv != "" {
cfg.Tailscale.AuthKey = getenv(cfg.Tailscale.AuthKeyEnv, "")
}

View File

@ -20,6 +20,8 @@ func clearConfigEnv(t *testing.T) {
"CRABBOX_TAILSCALE_HOSTNAME_TEMPLATE",
"CRABBOX_TAILSCALE_AUTH_KEY_ENV",
"CRABBOX_TAILSCALE_AUTH_KEY",
"CRABBOX_TAILSCALE_EXIT_NODE",
"CRABBOX_TAILSCALE_EXIT_NODE_ALLOW_LAN_ACCESS",
"CRABBOX_ACCESS_CLIENT_ID",
"CRABBOX_ACCESS_CLIENT_SECRET",
"CRABBOX_ACCESS_TOKEN",
@ -279,6 +281,8 @@ tailscale:
- tag:ci
hostnameTemplate: cbx-{slug}
authKeyEnv: TEST_TS_AUTH_KEY
exitNode: mac-studio.tailnet.ts.net
exitNodeAllowLanAccess: true
`), 0o600); err != nil {
t.Fatal(err)
}
@ -287,7 +291,7 @@ tailscale:
if err != nil {
t.Fatal(err)
}
if !cfg.Tailscale.Enabled || cfg.Network != NetworkTailscale || cfg.Tailscale.HostnameTemplate != "cbx-{slug}" || cfg.Tailscale.AuthKeyEnv != "TEST_TS_AUTH_KEY" {
if !cfg.Tailscale.Enabled || cfg.Network != NetworkTailscale || cfg.Tailscale.HostnameTemplate != "cbx-{slug}" || cfg.Tailscale.AuthKeyEnv != "TEST_TS_AUTH_KEY" || cfg.Tailscale.ExitNode != "mac-studio.tailnet.ts.net" || !cfg.Tailscale.ExitNodeAllowLANAccess {
t.Fatalf("tailscale config not loaded: network=%s tailscale=%#v", cfg.Network, cfg.Tailscale)
}
if len(cfg.Tailscale.Tags) != 2 || cfg.Tailscale.Tags[1] != "tag:ci" {
@ -315,6 +319,8 @@ func TestEnvOverridesConfig(t *testing.T) {
t.Setenv("CRABBOX_TAILSCALE_TAGS", "tag:crabbox,tag:ci")
t.Setenv("CRABBOX_TAILSCALE_HOSTNAME_TEMPLATE", "lease-{id}")
t.Setenv("CRABBOX_TAILSCALE_AUTH_KEY", "tskey-secret")
t.Setenv("CRABBOX_TAILSCALE_EXIT_NODE", "mac-studio.tailnet.ts.net")
t.Setenv("CRABBOX_TAILSCALE_EXIT_NODE_ALLOW_LAN_ACCESS", "1")
t.Setenv("CRABBOX_TARGET", "macos")
t.Setenv("CRABBOX_STATIC_HOST", "mac.local")
t.Setenv("DAYTONA_API_KEY", "daytona-api-file")
@ -370,7 +376,7 @@ func TestEnvOverridesConfig(t *testing.T) {
if cfg.TargetOS != targetMacOS || cfg.Static.Host != "mac.local" {
t.Fatalf("unexpected target env: target=%s static=%#v", cfg.TargetOS, cfg.Static)
}
if cfg.Network != NetworkPublic || cfg.Tailscale.AuthKey != "tskey-secret" || cfg.Tailscale.HostnameTemplate != "lease-{id}" {
if cfg.Network != NetworkPublic || cfg.Tailscale.AuthKey != "tskey-secret" || cfg.Tailscale.HostnameTemplate != "lease-{id}" || cfg.Tailscale.ExitNode != "mac-studio.tailnet.ts.net" || !cfg.Tailscale.ExitNodeAllowLANAccess {
t.Fatalf("unexpected tailscale env: network=%s tailscale=%#v", cfg.Network, cfg.Tailscale)
}
if len(cfg.Tailscale.Tags) != 2 || cfg.Tailscale.Tags[1] != "tag:ci" {
@ -396,12 +402,14 @@ func TestTailscaleEnvOverrides(t *testing.T) {
t.Setenv("CRABBOX_TAILSCALE_TAGS", "tag:crabbox,tag:ci")
t.Setenv("CRABBOX_TAILSCALE_HOSTNAME_TEMPLATE", "lease-{slug}")
t.Setenv("CRABBOX_TAILSCALE_AUTH_KEY", "tskey-secret")
t.Setenv("CRABBOX_TAILSCALE_EXIT_NODE", "100.100.100.100")
t.Setenv("CRABBOX_TAILSCALE_EXIT_NODE_ALLOW_LAN_ACCESS", "true")
cfg, err := loadConfig()
if err != nil {
t.Fatal(err)
}
if cfg.Network != NetworkTailscale || !cfg.Tailscale.Enabled || cfg.Tailscale.AuthKey != "tskey-secret" || cfg.Tailscale.HostnameTemplate != "lease-{slug}" {
if cfg.Network != NetworkTailscale || !cfg.Tailscale.Enabled || cfg.Tailscale.AuthKey != "tskey-secret" || cfg.Tailscale.HostnameTemplate != "lease-{slug}" || cfg.Tailscale.ExitNode != "100.100.100.100" || !cfg.Tailscale.ExitNodeAllowLANAccess {
t.Fatalf("unexpected tailscale env: network=%s tailscale=%#v", cfg.Network, cfg.Tailscale)
}
if len(cfg.Tailscale.Tags) != 2 || cfg.Tailscale.Tags[1] != "tag:ci" {

View File

@ -311,31 +311,33 @@ func (c *CoordinatorClient) CreateLease(ctx context.Context, cfg Config, publicK
slug = newLeaseSlug(leaseID)
}
err := c.do(ctx, http.MethodPost, "/v1/leases", map[string]any{
"leaseID": leaseID,
"slug": slug,
"profile": cfg.Profile,
"provider": cfg.Provider,
"target": cfg.TargetOS,
"windowsMode": cfg.WindowsMode,
"desktop": cfg.Desktop,
"browser": cfg.Browser,
"code": cfg.Code,
"tailscale": cfg.Tailscale.Enabled,
"tailscaleTags": cfg.Tailscale.Tags,
"tailscaleHostname": cfg.Tailscale.Hostname,
"class": cfg.Class,
"serverType": cfg.ServerType,
"serverTypeExplicit": cfg.ServerTypeExplicit,
"location": cfg.Location,
"image": cfg.Image,
"awsRegion": cfg.AWSRegion,
"awsAMI": cfg.AWSAMI,
"awsSGID": cfg.AWSSGID,
"awsSubnetID": cfg.AWSSubnetID,
"awsProfile": cfg.AWSProfile,
"awsRootGB": cfg.AWSRootGB,
"awsSSHCIDRs": cfg.AWSSSHCIDRs,
"awsMacHostID": cfg.AWSMacHostID,
"leaseID": leaseID,
"slug": slug,
"profile": cfg.Profile,
"provider": cfg.Provider,
"target": cfg.TargetOS,
"windowsMode": cfg.WindowsMode,
"desktop": cfg.Desktop,
"browser": cfg.Browser,
"code": cfg.Code,
"tailscale": cfg.Tailscale.Enabled,
"tailscaleTags": cfg.Tailscale.Tags,
"tailscaleHostname": cfg.Tailscale.Hostname,
"tailscaleExitNode": cfg.Tailscale.ExitNode,
"tailscaleExitNodeAllowLanAccess": cfg.Tailscale.ExitNodeAllowLANAccess,
"class": cfg.Class,
"serverType": cfg.ServerType,
"serverTypeExplicit": cfg.ServerTypeExplicit,
"location": cfg.Location,
"image": cfg.Image,
"awsRegion": cfg.AWSRegion,
"awsAMI": cfg.AWSAMI,
"awsSGID": cfg.AWSSGID,
"awsSubnetID": cfg.AWSSubnetID,
"awsProfile": cfg.AWSProfile,
"awsRootGB": cfg.AWSRootGB,
"awsSSHCIDRs": cfg.AWSSSHCIDRs,
"awsMacHostID": cfg.AWSMacHostID,
"capacity": map[string]any{
"market": cfg.Capacity.Market,
"strategy": cfg.Capacity.Strategy,

View File

@ -36,6 +36,9 @@ func (a App) inspect(ctx context.Context, args []string) error {
fmt.Fprintf(a.Stdout, "id=%s\nslug=%s\nprovider=%s\ntarget=%s\nwindows_mode=%s\nstate=%s\nserver=%s\nhost=%s\nnetwork=%s\nssh=%s -p %s %s@%s\nssh_fallback_ports=%s\nidle_for=%s\nidle_timeout=%s\nlast_touched=%s\nexpires=%s\n", state.ID, blank(state.Slug, "-"), state.Provider, state.TargetOS, blank(state.WindowsMode, "-"), state.State, state.ServerID, state.Host, state.Network, state.SSHKey, state.SSHPort, state.SSHUser, state.SSHHost, blank(strings.Join(state.SSHFallbackPorts, ","), "-"), blank(state.IdleFor, "-"), blank(state.IdleTimeout, "-"), blank(state.LastTouchedAt, "-"), blank(state.ExpiresAt, "-"))
if state.Tailscale != nil && state.Tailscale.Enabled {
fmt.Fprintf(a.Stdout, "tailscale.state=%s\ntailscale.hostname=%s\ntailscale.fqdn=%s\ntailscale.ipv4=%s\ntailscale.tags=%s\n", blank(state.Tailscale.State, "-"), blank(state.Tailscale.Hostname, "-"), blank(state.Tailscale.FQDN, "-"), blank(state.Tailscale.IPv4, "-"), blank(strings.Join(state.Tailscale.Tags, ","), "-"))
if state.Tailscale.ExitNode != "" {
fmt.Fprintf(a.Stdout, "tailscale.exit_node=%s\ntailscale.exit_node_allow_lan_access=%t\n", state.Tailscale.ExitNode, state.Tailscale.ExitNodeAllowLANAccess)
}
}
for key, value := range state.Labels {
fmt.Fprintf(a.Stdout, "label.%s=%s\n", key, value)

View File

@ -17,30 +17,36 @@ const (
)
type TailscaleConfig struct {
Enabled bool
Tags []string
HostnameTemplate string
Hostname string
AuthKeyEnv string
AuthKey string
Enabled bool
Tags []string
HostnameTemplate string
Hostname string
AuthKeyEnv string
AuthKey string
ExitNode string
ExitNodeAllowLANAccess bool
}
type TailscaleMetadata struct {
Enabled bool `json:"enabled"`
Hostname string `json:"hostname,omitempty"`
FQDN string `json:"fqdn,omitempty"`
IPv4 string `json:"ipv4,omitempty"`
Tags []string `json:"tags,omitempty"`
State string `json:"state,omitempty"`
Error string `json:"error,omitempty"`
Enabled bool `json:"enabled"`
Hostname string `json:"hostname,omitempty"`
FQDN string `json:"fqdn,omitempty"`
IPv4 string `json:"ipv4,omitempty"`
Tags []string `json:"tags,omitempty"`
State string `json:"state,omitempty"`
Error string `json:"error,omitempty"`
ExitNode string `json:"exitNode,omitempty"`
ExitNodeAllowLANAccess bool `json:"exitNodeAllowLanAccess,omitempty"`
}
type networkFlagValues struct {
Network *string
Tailscale *bool
TailscaleTags *string
TailscaleHost *string
TailscaleKeyEnv *string
Network *string
Tailscale *bool
TailscaleTags *string
TailscaleHost *string
TailscaleKeyEnv *string
TailscaleExitNode *string
TailscaleExitNodeAllowLAN *bool
}
type networkModeFlagValues struct {
@ -75,11 +81,13 @@ func applyNetworkModeFlagOverride(cfg *Config, fs *flag.FlagSet, values networkM
func registerNetworkFlags(fs *flag.FlagSet, defaults Config) networkFlagValues {
return networkFlagValues{
Network: fs.String("network", string(defaults.Network), "network mode: auto, tailscale, or public"),
Tailscale: fs.Bool("tailscale", defaults.Tailscale.Enabled, "join new managed leases to the configured tailnet"),
TailscaleTags: fs.String("tailscale-tags", strings.Join(defaults.Tailscale.Tags, ","), "comma-separated Tailscale tags for new managed leases"),
TailscaleHost: fs.String("tailscale-hostname-template", defaults.Tailscale.HostnameTemplate, "Tailscale hostname template for new managed leases"),
TailscaleKeyEnv: fs.String("tailscale-auth-key-env", defaults.Tailscale.AuthKeyEnv, "environment variable containing a direct-provider Tailscale auth key"),
Network: fs.String("network", string(defaults.Network), "network mode: auto, tailscale, or public"),
Tailscale: fs.Bool("tailscale", defaults.Tailscale.Enabled, "join new managed leases to the configured tailnet"),
TailscaleTags: fs.String("tailscale-tags", strings.Join(defaults.Tailscale.Tags, ","), "comma-separated Tailscale tags for new managed leases"),
TailscaleHost: fs.String("tailscale-hostname-template", defaults.Tailscale.HostnameTemplate, "Tailscale hostname template for new managed leases"),
TailscaleKeyEnv: fs.String("tailscale-auth-key-env", defaults.Tailscale.AuthKeyEnv, "environment variable containing a direct-provider Tailscale auth key"),
TailscaleExitNode: fs.String("tailscale-exit-node", defaults.Tailscale.ExitNode, "Tailscale exit node name or 100.x address for new managed leases"),
TailscaleExitNodeAllowLAN: fs.Bool("tailscale-exit-node-allow-lan-access", defaults.Tailscale.ExitNodeAllowLANAccess, "allow LAN access while using the Tailscale exit node"),
}
}
@ -104,6 +112,12 @@ func applyNetworkFlagOverrides(cfg *Config, fs *flag.FlagSet, values networkFlag
cfg.Tailscale.AuthKeyEnv = strings.TrimSpace(*values.TailscaleKeyEnv)
cfg.Tailscale.AuthKey = getenv(cfg.Tailscale.AuthKeyEnv, "")
}
if flagWasSet(fs, "tailscale-exit-node") {
cfg.Tailscale.ExitNode = strings.TrimSpace(*values.TailscaleExitNode)
}
if flagWasSet(fs, "tailscale-exit-node-allow-lan-access") {
cfg.Tailscale.ExitNodeAllowLANAccess = *values.TailscaleExitNodeAllowLAN
}
return validateNetworkConfig(*cfg)
}
@ -136,6 +150,9 @@ func validateNetworkConfig(cfg Config) error {
if strings.TrimSpace(cfg.Tailscale.HostnameTemplate) == "" {
return exit(2, "tailscale.hostnameTemplate must not be empty")
}
if cfg.Tailscale.ExitNodeAllowLANAccess && strings.TrimSpace(cfg.Tailscale.ExitNode) == "" {
return exit(2, "tailscale.exitNodeAllowLanAccess requires tailscale.exitNode")
}
if cfg.TargetOS != targetLinux {
return exit(2, "--tailscale managed provisioning currently supports target=linux only")
}
@ -241,12 +258,14 @@ func tailscaleTargetHost(meta TailscaleMetadata) string {
func serverTailscaleMetadata(server Server) TailscaleMetadata {
labels := server.Labels
meta := TailscaleMetadata{
Enabled: labelBool(labels["tailscale"]),
Hostname: labels["tailscale_hostname"],
FQDN: labels["tailscale_fqdn"],
IPv4: labels["tailscale_ipv4"],
State: labels["tailscale_state"],
Error: labels["tailscale_error"],
Enabled: labelBool(labels["tailscale"]),
Hostname: labels["tailscale_hostname"],
FQDN: labels["tailscale_fqdn"],
IPv4: labels["tailscale_ipv4"],
State: labels["tailscale_state"],
Error: labels["tailscale_error"],
ExitNode: labels["tailscale_exit_node"],
ExitNodeAllowLANAccess: labelBool(labels["tailscale_exit_node_allow_lan_access"]),
}
if tags := splitCommaList(labels["tailscale_tags"]); len(tags) > 0 {
meta.Tags = tags
@ -279,6 +298,12 @@ func applyTailscaleMetadataToServer(server *Server, meta TailscaleMetadata) {
if meta.Error != "" {
server.Labels["tailscale_error"] = meta.Error
}
if meta.ExitNode != "" {
server.Labels["tailscale_exit_node"] = meta.ExitNode
}
if meta.ExitNodeAllowLANAccess {
server.Labels["tailscale_exit_node_allow_lan_access"] = "true"
}
}
func (a App) refreshTailscaleMetadata(ctx context.Context, cfg Config, coord *CoordinatorClient, useCoordinator bool, server *Server, target SSHTarget, leaseID string) {
@ -296,6 +321,8 @@ func (a App) refreshTailscaleMetadata(ctx context.Context, cfg Config, coord *Co
meta.Hostname = firstNonEmpty(meta.Hostname, existing.Hostname)
meta.FQDN = firstNonEmpty(meta.FQDN, existing.FQDN)
meta.Tags = appendUniqueStrings(existing.Tags, meta.Tags...)
meta.ExitNode = firstNonEmpty(meta.ExitNode, existing.ExitNode)
meta.ExitNodeAllowLANAccess = meta.ExitNodeAllowLANAccess || existing.ExitNodeAllowLANAccess
}
applyTailscaleMetadataToServer(server, meta)
if useCoordinator && coord != nil && leaseID != "" {
@ -313,7 +340,11 @@ func readRemoteTailscaleMetadata(ctx context.Context, target SSHTarget) (Tailsca
printf '\n'
if [ -f /var/lib/crabbox/tailscale-hostname ]; then cat /var/lib/crabbox/tailscale-hostname; fi
printf '\n'
if [ -f /var/lib/crabbox/tailscale-fqdn ]; then cat /var/lib/crabbox/tailscale-fqdn; fi`)
if [ -f /var/lib/crabbox/tailscale-fqdn ]; then cat /var/lib/crabbox/tailscale-fqdn; fi
printf '\n'
if [ -f /var/lib/crabbox/tailscale-exit-node ]; then cat /var/lib/crabbox/tailscale-exit-node; fi
printf '\n'
if [ -f /var/lib/crabbox/tailscale-exit-node-allow-lan-access ]; then cat /var/lib/crabbox/tailscale-exit-node-allow-lan-access; fi`)
if err != nil {
return TailscaleMetadata{}, err
}
@ -328,6 +359,12 @@ if [ -f /var/lib/crabbox/tailscale-fqdn ]; then cat /var/lib/crabbox/tailscale-f
if len(lines) > 2 {
meta.FQDN = strings.TrimSpace(lines[2])
}
if len(lines) > 3 {
meta.ExitNode = strings.TrimSpace(lines[3])
}
if len(lines) > 4 {
meta.ExitNodeAllowLANAccess = labelBool(strings.TrimSpace(lines[4]))
}
if meta.IPv4 == "" {
return TailscaleMetadata{}, fmt.Errorf("remote tailscale metadata missing ipv4")
}

View File

@ -49,6 +49,10 @@ func directLeaseLabels(cfg Config, leaseID, slug, provider, market string, keep
labels["tailscale_state"] = "requested"
labels["tailscale_hostname"] = cfg.Tailscale.Hostname
labels["tailscale_tags"] = strings.Join(cfg.Tailscale.Tags, ",")
if cfg.Tailscale.ExitNode != "" {
labels["tailscale_exit_node"] = cfg.Tailscale.ExitNode
labels["tailscale_exit_node_allow_lan_access"] = fmt.Sprint(cfg.Tailscale.ExitNodeAllowLANAccess)
}
}
return sanitizeProviderLabels(labels)
}

View File

@ -50,6 +50,8 @@ func TestDirectLeaseLabelsIncludeNonSecretTailscaleMetadata(t *testing.T) {
cfg.Tailscale.Hostname = "crabbox-blue-lobster"
cfg.Tailscale.Tags = []string{"tag:crabbox"}
cfg.Tailscale.AuthKey = "tskey-secret"
cfg.Tailscale.ExitNode = "mac-studio.tailnet.ts.net"
cfg.Tailscale.ExitNodeAllowLANAccess = true
labels := directLeaseLabels(cfg, "cbx_abcdef123456", "blue-lobster", "hetzner", "", true, time.Now())
if labels["tailscale"] != "true" || labels["tailscale_state"] != "requested" {
t.Fatalf("tailscale labels missing: %#v", labels)
@ -57,6 +59,9 @@ func TestDirectLeaseLabelsIncludeNonSecretTailscaleMetadata(t *testing.T) {
if labels["tailscale_hostname"] != "crabbox-blue-lobster" || labels["tailscale_tags"] != "tag_crabbox" {
t.Fatalf("tailscale metadata labels unexpected: %#v", labels)
}
if labels["tailscale_exit_node"] != "mac-studio.tailnet.ts.net" || labels["tailscale_exit_node_allow_lan_access"] != "true" {
t.Fatalf("tailscale exit-node labels unexpected: %#v", labels)
}
for key, value := range labels {
if strings.Contains(value, "tskey-secret") || strings.Contains(key, "auth") {
t.Fatalf("tailscale secret leaked through label %s=%q", key, value)

View File

@ -475,12 +475,23 @@ function tailscaleBootstrap(config: LeaseConfig): string {
exit 1`;
}
const sshUser = config.sshUser.trim() || "crabbox";
const upArgs = [
`--auth-key="$TS_AUTHKEY"`,
`--hostname=${shellQuote(config.tailscaleHostname)}`,
`--advertise-tags=${shellQuote(config.tailscaleTags.join(","))}`,
];
if (config.tailscaleExitNode) {
upArgs.push(`--exit-node=${shellQuote(config.tailscaleExitNode)}`);
if (config.tailscaleExitNodeAllowLanAccess) {
upArgs.push("--exit-node-allow-lan-access");
}
}
return ` retry sh -c 'curl -fsSL https://tailscale.com/install.sh | sh'
systemctl enable --now tailscaled || service tailscaled start || true
install -d -m 0750 -o ${shellQuote(sshUser)} -g ${shellQuote(sshUser)} /var/lib/crabbox
set +x
TS_AUTHKEY=${shellQuote(config.tailscaleAuthKey)}
tailscale up --auth-key="$TS_AUTHKEY" --hostname=${shellQuote(config.tailscaleHostname)} --advertise-tags=${shellQuote(config.tailscaleTags.join(","))}
tailscale up ${upArgs.join(" ")}
unset TS_AUTHKEY
set -x
ts_ip=""
@ -492,6 +503,10 @@ function tailscaleBootstrap(config: LeaseConfig): string {
test -n "$ts_ip"
printf '%s\\n' "$ts_ip" > /var/lib/crabbox/tailscale-ipv4
printf '%s\\n' ${shellQuote(config.tailscaleHostname)} > /var/lib/crabbox/tailscale-hostname
if [ -n ${shellQuote(config.tailscaleExitNode)} ]; then
printf '%s\\n' ${shellQuote(config.tailscaleExitNode)} > /var/lib/crabbox/tailscale-exit-node
printf '%s\\n' ${shellQuote(String(config.tailscaleExitNodeAllowLanAccess))} > /var/lib/crabbox/tailscale-exit-node-allow-lan-access
fi
if tailscale status --json >/var/lib/crabbox/tailscale-status.json 2>/dev/null; then
jq -r '.Self.DNSName // empty' /var/lib/crabbox/tailscale-status.json > /var/lib/crabbox/tailscale-fqdn || true
fi

View File

@ -11,6 +11,8 @@ export interface LeaseConfig {
tailscaleTags: string[];
tailscaleHostname: string;
tailscaleAuthKey: string;
tailscaleExitNode: string;
tailscaleExitNodeAllowLanAccess: boolean;
profile: string;
class: string;
serverType: string;
@ -79,6 +81,11 @@ export function leaseConfig(input: LeaseRequest): LeaseConfig {
if (!sshPublicKey) {
throw new Error("sshPublicKey is required");
}
const tailscaleExitNode = input.tailscaleExitNode?.trim() ?? "";
const tailscaleExitNodeAllowLanAccess = input.tailscaleExitNodeAllowLanAccess ?? false;
if (tailscaleExitNodeAllowLanAccess && !tailscaleExitNode) {
throw new Error("tailscaleExitNodeAllowLanAccess requires tailscaleExitNode");
}
return {
provider,
target,
@ -90,6 +97,8 @@ export function leaseConfig(input: LeaseRequest): LeaseConfig {
tailscaleTags: normalizeTailscaleTags(input.tailscaleTags ?? ["tag:crabbox"]),
tailscaleHostname: input.tailscaleHostname ?? "",
tailscaleAuthKey: "",
tailscaleExitNode,
tailscaleExitNodeAllowLanAccess,
profile: input.profile ?? "default",
class: machineClass,
serverType,

View File

@ -486,6 +486,10 @@ export class FleetDurableObject implements DurableObject {
tags: config.tailscaleTags,
state: "requested",
};
if (config.tailscaleExitNode) {
record.tailscale.exitNode = config.tailscaleExitNode;
record.tailscale.exitNodeAllowLanAccess = config.tailscaleExitNodeAllowLanAccess;
}
}
const limitError = enforceCostLimits(leases, record, costLimits(this.env), now);
if (limitError) {
@ -613,6 +617,13 @@ export class FleetDurableObject implements DurableObject {
slug,
config.provider,
);
config.tailscaleExitNode =
nonSecretString(input.tailscaleExitNode) || config.tailscaleExitNode;
config.tailscaleExitNodeAllowLanAccess =
input.tailscaleExitNodeAllowLanAccess ?? config.tailscaleExitNodeAllowLanAccess;
if (!config.tailscaleExitNode) {
config.tailscaleExitNodeAllowLanAccess = false;
}
config.tailscaleAuthKey = await createTailscaleAuthKey(this.env, {
hostname: config.tailscaleHostname,
tags: config.tailscaleTags,
@ -2026,6 +2037,7 @@ function mergeTailscaleMetadata(
const fqdn = nonSecretString(input.fqdn) || current?.fqdn;
const ipv4 = nonSecretString(input.ipv4) || current?.ipv4;
const error = nonSecretString(input.error) || current?.error;
const exitNode = nonSecretString(input.exitNode) || current?.exitNode;
if (hostname) {
merged.hostname = hostname;
}
@ -2038,6 +2050,11 @@ function mergeTailscaleMetadata(
if (error) {
merged.error = error;
}
if (exitNode) {
merged.exitNode = exitNode;
merged.exitNodeAllowLanAccess =
input.exitNodeAllowLanAccess ?? current?.exitNodeAllowLanAccess ?? false;
}
if (merged.state !== "failed") {
delete merged.error;
}

View File

@ -46,6 +46,12 @@ export function leaseProviderLabels(
labels["tailscale_state"] = "requested";
labels["tailscale_hostname"] = config.tailscaleHostname;
labels["tailscale_tags"] = config.tailscaleTags.join(",");
if (config.tailscaleExitNode) {
labels["tailscale_exit_node"] = config.tailscaleExitNode;
labels["tailscale_exit_node_allow_lan_access"] = String(
config.tailscaleExitNodeAllowLanAccess,
);
}
}
return sanitizeLabels({ ...labels, ...extra });
}

View File

@ -54,6 +54,8 @@ export interface LeaseRequest {
tailscale?: boolean;
tailscaleTags?: string[];
tailscaleHostname?: string;
tailscaleExitNode?: string;
tailscaleExitNodeAllowLanAccess?: boolean;
profile?: string;
class?: string;
serverType?: string;
@ -161,6 +163,8 @@ export interface TailscaleMetadata {
tags?: string[];
state?: "requested" | "ready" | "failed";
error?: string;
exitNode?: string;
exitNodeAllowLanAccess?: boolean;
}
export interface ProvisioningAttempt {

View File

@ -14,6 +14,8 @@ const config: LeaseConfig = {
tailscaleTags: ["tag:crabbox"],
tailscaleHostname: "",
tailscaleAuthKey: "",
tailscaleExitNode: "",
tailscaleExitNodeAllowLanAccess: false,
profile: "project-check",
class: "standard",
serverType: "c7a.8xlarge",
@ -153,15 +155,23 @@ describe("cloud-init bootstrap", () => {
tailscaleTags: ["tag:crabbox"],
tailscaleHostname: "crabbox-blue-lobster",
tailscaleAuthKey: "tskey-secret",
tailscaleExitNode: "mac-studio.tailnet.ts.net",
tailscaleExitNodeAllowLanAccess: true,
});
expect(got).toContain("https://tailscale.com/install.sh");
expect(got).toContain("install -d -m 0750 -o 'runner' -g 'runner' /var/lib/crabbox");
expect(got).toContain(
"tailscale up --auth-key=\"$TS_AUTHKEY\" --hostname='crabbox-blue-lobster' --advertise-tags='tag:crabbox'",
"tailscale up --auth-key=\"$TS_AUTHKEY\" --hostname='crabbox-blue-lobster' --advertise-tags='tag:crabbox' --exit-node='mac-studio.tailnet.ts.net' --exit-node-allow-lan-access",
);
expect(got).toContain(
"printf '%s\\n' 'crabbox-blue-lobster' > /var/lib/crabbox/tailscale-hostname",
);
expect(got).toContain(
"printf '%s\\n' 'mac-studio.tailnet.ts.net' > /var/lib/crabbox/tailscale-exit-node",
);
expect(got).toContain(
"printf '%s\\n' 'true' > /var/lib/crabbox/tailscale-exit-node-allow-lan-access",
);
expect(got).toContain("chown 'runner:runner' /var/lib/crabbox/tailscale-* || true");
expect(got).toContain("test -s /var/lib/crabbox/tailscale-ipv4");
expect(got).toContain("grep -Eq '^100\\.' /var/lib/crabbox/tailscale-ipv4");

View File

@ -176,11 +176,15 @@ describe("lease config", () => {
tailscale: true,
tailscaleTags: ["tag:Crabbox", "tag:ci", "invalid"],
tailscaleHostname: "crabbox-blue-lobster",
tailscaleExitNode: "mac-studio.tailnet.ts.net",
tailscaleExitNodeAllowLanAccess: true,
});
expect(config.tailscale).toBe(true);
expect(config.tailscaleTags).toEqual(["tag:crabbox", "tag:ci"]);
expect(config.tailscaleHostname).toBe("crabbox-blue-lobster");
expect(config.tailscaleAuthKey).toBe("");
expect(config.tailscaleExitNode).toBe("mac-studio.tailnet.ts.net");
expect(config.tailscaleExitNodeAllowLanAccess).toBe(true);
});
it("uses AWS defaults when requested", () => {

View File

@ -109,6 +109,8 @@ describe("fleet lease identity and idle", () => {
tailscaleAuthKey?: string;
tailscaleHostname?: string;
tailscaleTags?: string[];
tailscaleExitNode?: string;
tailscaleExitNodeAllowLanAccess?: boolean;
}
| undefined;
vi.stubGlobal(
@ -150,6 +152,8 @@ describe("fleet lease identity and idle", () => {
tailscale: true,
tailscaleTags: ["tag:ci"],
tailscaleHostname: "crabbox-{slug}",
tailscaleExitNode: "mac-studio.tailnet.ts.net",
tailscaleExitNodeAllowLanAccess: true,
sshPublicKey: "ssh-ed25519 test",
},
}),
@ -161,6 +165,8 @@ describe("fleet lease identity and idle", () => {
hostname: "crabbox-blue-lobster",
tags: ["tag:ci"],
state: "requested",
exitNode: "mac-studio.tailnet.ts.net",
exitNodeAllowLanAccess: true,
});
expect(JSON.stringify(lease)).not.toContain("tskey-oneoff");
expect(providerConfig).toMatchObject({
@ -168,6 +174,8 @@ describe("fleet lease identity and idle", () => {
tailscaleAuthKey: "tskey-oneoff",
tailscaleHostname: "crabbox-blue-lobster",
tailscaleTags: ["tag:ci"],
tailscaleExitNode: "mac-studio.tailnet.ts.net",
tailscaleExitNodeAllowLanAccess: true,
});
const update = await fleet.fetch(
@ -181,6 +189,8 @@ describe("fleet lease identity and idle", () => {
hostname: "crabbox-blue-lobster",
fqdn: "crabbox-blue-lobster.example.ts.net",
ipv4: "100.64.0.10",
exitNode: "mac-studio.tailnet.ts.net",
exitNodeAllowLanAccess: true,
state: "ready",
},
}),
@ -188,6 +198,7 @@ describe("fleet lease identity and idle", () => {
expect(update.status).toBe(200);
const updated = (await update.json()) as { lease: LeaseRecord };
expect(updated.lease.tailscale?.ipv4).toBe("100.64.0.10");
expect(updated.lease.tailscale?.exitNode).toBe("mac-studio.tailnet.ts.net");
expect(updated.lease.tailscale?.state).toBe("ready");
});
@ -2122,6 +2133,8 @@ function fakeProvider(
tailscaleAuthKey?: string;
tailscaleHostname?: string;
tailscaleTags?: string[];
tailscaleExitNode?: string;
tailscaleExitNodeAllowLanAccess?: boolean;
}) => void,
result: {
provider?: "hetzner" | "aws";

View File

@ -15,6 +15,8 @@ describe("provider labels", () => {
tailscaleTags: ["tag:crabbox"],
tailscaleHostname: "",
tailscaleAuthKey: "",
tailscaleExitNode: "",
tailscaleExitNodeAllowLanAccess: false,
profile: "default",
class: "beast",
serverType: "c7a.48xlarge",
@ -70,6 +72,8 @@ describe("provider labels", () => {
tailscaleTags: ["tag:crabbox"],
tailscaleHostname: "crabbox-blue-lobster",
tailscaleAuthKey: "tskey-secret",
tailscaleExitNode: "mac-studio.tailnet.ts.net",
tailscaleExitNodeAllowLanAccess: true,
profile: "default",
class: "beast",
serverType: "c7a.48xlarge",
@ -109,6 +113,8 @@ describe("provider labels", () => {
expect(labels.tailscale).toBe("true");
expect(labels.tailscale_hostname).toBe("crabbox-blue-lobster");
expect(labels.tailscale_tags).toBe("tag_crabbox");
expect(labels.tailscale_exit_node).toBe("mac-studio.tailnet.ts.net");
expect(labels.tailscale_exit_node_allow_lan_access).toBe("true");
expect(Object.values(labels).join(" ")).not.toContain("tskey-secret");
});
});