crabbox/internal/cli/network.go
2026-05-07 01:14:26 +01:00

446 lines
15 KiB
Go

package cli
import (
"context"
"flag"
"fmt"
"strings"
"time"
)
type NetworkMode string
const (
NetworkAuto NetworkMode = "auto"
NetworkTailscale NetworkMode = "tailscale"
NetworkPublic NetworkMode = "public"
)
type TailscaleConfig struct {
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"`
ExitNode string `json:"exitNode,omitempty"`
ExitNodeAllowLANAccess bool `json:"exitNodeAllowLanAccess,omitempty"`
}
type networkFlagValues struct {
Network *string
Tailscale *bool
TailscaleTags *string
TailscaleHost *string
TailscaleKeyEnv *string
TailscaleExitNode *string
TailscaleExitNodeAllowLAN *bool
}
type networkModeFlagValues struct {
Network *string
}
type resolvedNetworkTarget struct {
Target SSHTarget
Network NetworkMode
FallbackReason string
}
func registerNetworkModeFlag(fs *flag.FlagSet, defaults Config) networkModeFlagValues {
return networkModeFlagValues{
Network: fs.String("network", string(defaults.Network), "network mode: auto, tailscale, or public"),
}
}
func applyNetworkModeFlagOverride(cfg *Config, fs *flag.FlagSet, values networkModeFlagValues) error {
if flagWasSet(fs, "network") {
mode, err := parseNetworkMode(*values.Network)
if err != nil {
return err
}
cfg.Network = mode
}
if _, err := parseNetworkMode(string(cfg.Network)); err != nil {
return err
}
return nil
}
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"),
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"),
}
}
func applyNetworkFlagOverrides(cfg *Config, fs *flag.FlagSet, values networkFlagValues) error {
if flagWasSet(fs, "network") {
mode, err := parseNetworkMode(*values.Network)
if err != nil {
return err
}
cfg.Network = mode
}
if flagWasSet(fs, "tailscale") {
cfg.Tailscale.Enabled = *values.Tailscale
}
if flagWasSet(fs, "tailscale-tags") {
cfg.Tailscale.Tags = normalizeTailscaleTags(splitCommaList(*values.TailscaleTags))
}
if flagWasSet(fs, "tailscale-hostname-template") {
cfg.Tailscale.HostnameTemplate = strings.TrimSpace(*values.TailscaleHost)
}
if flagWasSet(fs, "tailscale-auth-key-env") {
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)
}
func parseNetworkMode(value string) (NetworkMode, error) {
switch NetworkMode(strings.ToLower(strings.TrimSpace(value))) {
case "", NetworkAuto:
return NetworkAuto, nil
case NetworkTailscale:
return NetworkTailscale, nil
case NetworkPublic:
return NetworkPublic, nil
default:
return "", exit(2, "network must be auto, tailscale, or public")
}
}
func validateNetworkConfig(cfg Config) error {
if _, err := parseNetworkMode(string(cfg.Network)); err != nil {
return err
}
if cfg.Tailscale.Enabled {
if len(cfg.Tailscale.Tags) == 0 {
return exit(2, "tailscale.tags must include at least one tag")
}
for _, tag := range cfg.Tailscale.Tags {
if !validTailscaleTag(tag) {
return exit(2, "invalid Tailscale tag %q; tags must look like tag:crabbox", tag)
}
}
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")
}
if isBlacksmithProvider(cfg.Provider) {
return exit(2, "--tailscale is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
}
if isStaticProvider(cfg.Provider) {
return exit(2, "--tailscale only provisions managed leases; set static.host to a MagicDNS name or 100.x address and use --network tailscale")
}
}
return nil
}
func normalizeTailscaleTags(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
continue
}
out = append(out, value)
}
return appendUniqueStrings(nil, out...)
}
func validTailscaleTag(value string) bool {
if !strings.HasPrefix(value, "tag:") {
return false
}
name := strings.TrimPrefix(value, "tag:")
if name == "" || len(name) > 63 {
return false
}
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
continue
}
return false
}
return true
}
func renderTailscaleHostname(template, leaseID, slug, provider string) string {
value := strings.TrimSpace(template)
if value == "" {
value = "crabbox-{slug}"
}
replacements := map[string]string{
"{id}": strings.ReplaceAll(leaseID, "_", "-"),
"{slug}": normalizeLeaseSlug(slug),
"{provider}": normalizeLeaseSlug(provider),
}
for key, replacement := range replacements {
value = strings.ReplaceAll(value, key, replacement)
}
value = normalizeLeaseSlug(value)
if value == "" {
value = "crabbox-" + strings.ReplaceAll(leaseID, "_", "-")
}
return value
}
func resolveNetworkTarget(ctx context.Context, cfg Config, server Server, target SSHTarget) (resolvedNetworkTarget, error) {
meta := serverTailscaleMetadata(server)
switch cfg.Network {
case NetworkPublic:
return resolvedNetworkTarget{Target: target, Network: NetworkPublic}, nil
case NetworkTailscale:
host := tailscaleTargetHost(meta)
if host == "" {
if isStaticProvider(cfg.Provider) || server.Provider == staticProvider {
if !probeSSHTransport(ctx, &target, 6*time.Second) {
return resolvedNetworkTarget{}, exit(5, "network=tailscale requested for static host %s but SSH is not reachable; is this client joined to the tailnet?", target.Host)
}
return resolvedNetworkTarget{Target: target, Network: NetworkTailscale}, nil
}
return resolvedNetworkTarget{}, exit(5, "network=tailscale requested but lease %s has no tailnet address", blank(server.Labels["lease"], server.Name))
}
next := target
next.Host = host
if !probeSSHTransport(ctx, &next, 6*time.Second) {
return resolvedNetworkTarget{}, exit(5, "network=tailscale requested but %s is not reachable over SSH; is this client joined to the tailnet?", host)
}
return resolvedNetworkTarget{Target: next, Network: NetworkTailscale}, nil
default:
host := tailscaleTargetHost(meta)
if host == "" {
return resolvedNetworkTarget{Target: target, Network: NetworkPublic}, nil
}
next := target
next.Host = host
if probeSSHTransport(ctx, &next, 5*time.Second) {
return resolvedNetworkTarget{Target: next, Network: NetworkTailscale}, nil
}
return resolvedNetworkTarget{Target: target, Network: NetworkPublic, FallbackReason: "tailscale_unreachable"}, nil
}
}
func bootstrapNetworkTarget(cfg Config, server Server, target SSHTarget) SSHTarget {
if !preferTailscaleBootstrap(cfg, server) {
return target
}
host := tailscaleTargetHost(serverTailscaleMetadata(server))
if host == "" {
return target
}
next := target
next.Host = host
next.NetworkKind = NetworkTailscale
return next
}
func preferTailscaleBootstrap(cfg Config, server Server) bool {
if cfg.Network == NetworkPublic {
return false
}
if cfg.Network == NetworkTailscale {
return true
}
meta := serverTailscaleMetadata(server)
return meta.Enabled && meta.ExitNode != ""
}
func tailscaleTargetHost(meta TailscaleMetadata) string {
return firstNonEmpty(meta.FQDN, meta.IPv4, meta.Hostname)
}
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"],
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
}
return meta
}
func applyTailscaleMetadataToServer(server *Server, meta TailscaleMetadata) {
if server.Labels == nil {
server.Labels = map[string]string{}
}
if meta.Enabled {
server.Labels["tailscale"] = "true"
}
if meta.Hostname != "" {
server.Labels["tailscale_hostname"] = meta.Hostname
}
if meta.FQDN != "" {
server.Labels["tailscale_fqdn"] = meta.FQDN
}
if meta.IPv4 != "" {
server.Labels["tailscale_ipv4"] = meta.IPv4
}
if len(meta.Tags) > 0 {
server.Labels["tailscale_tags"] = strings.Join(meta.Tags, ",")
}
if meta.State != "" {
server.Labels["tailscale_state"] = meta.State
}
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) {
if server == nil || !serverTailscaleMetadata(*server).Enabled {
return
}
meta, err := readRemoteTailscaleMetadata(ctx, target)
if err != nil {
meta = serverTailscaleMetadata(*server)
meta.State = "failed"
meta.Error = err.Error()
fmt.Fprintf(a.Stderr, "warning: tailscale metadata unavailable for %s: %v\n", leaseID, err)
} else {
existing := serverTailscaleMetadata(*server)
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 != "" {
if lease, err := coord.UpdateLeaseTailscale(ctx, leaseID, meta); err == nil {
updated, _, _ := leaseToServerTarget(lease, cfg)
*server = updated
} else {
fmt.Fprintf(a.Stderr, "warning: tailscale metadata update failed for %s: %v\n", leaseID, err)
}
}
}
func readRemoteTailscaleMetadata(ctx context.Context, target SSHTarget) (TailscaleMetadata, error) {
out, err := runSSHOutput(ctx, target, `if [ -f /var/lib/crabbox/tailscale-ipv4 ]; then cat /var/lib/crabbox/tailscale-ipv4; fi
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
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
}
lines := strings.Split(out, "\n")
meta := TailscaleMetadata{Enabled: true, State: "ready"}
if len(lines) > 0 {
meta.IPv4 = strings.TrimSpace(lines[0])
}
if len(lines) > 1 {
meta.Hostname = strings.TrimSpace(lines[1])
}
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")
}
return meta, nil
}
func validateTailscaleExitNodeEgress(ctx context.Context, server Server, target SSHTarget) error {
meta := serverTailscaleMetadata(server)
if strings.TrimSpace(meta.ExitNode) == "" {
return nil
}
command := tailscaleExitNodeEgressCheckScript()
if out, err := runSSHCombinedOutput(ctx, target, command); err != nil {
detail := strings.TrimSpace(out)
if detail == "" {
detail = err.Error()
}
return exit(5, "tailscale exit node %s joined but remote internet egress failed; verify the exit node is approved and forwarding internet traffic: %s", meta.ExitNode, detail)
}
return nil
}
func tailscaleExitNodeEgressCheckScript() string {
return `set -eu
if ! command -v tailscale >/dev/null 2>&1; then
printf '%s\n' "tailscale is not installed for exit-node egress check" >&2
exit 87
fi
prefs="$(tailscale debug prefs 2>/dev/null)" || {
printf '%s\n' "tailscale prefs unavailable for exit-node egress check" >&2
exit 88
}
case "$prefs" in
*'"ExitNodeID": ""'*|*'"ExitNodeID":""'*)
printf '%s\n' "exit node is not selected in tailscale prefs" >&2
exit 86
;;
*'"ExitNodeID":'*)
;;
*)
printf '%s\n' "tailscale prefs did not include ExitNodeID" >&2
exit 89
;;
esac
if command -v curl >/dev/null 2>&1; then
timeout 12 sh -c 'curl -4fsS --connect-timeout 5 https://ifconfig.me/ip || curl -4fsS --connect-timeout 5 https://icanhazip.com' >/tmp/crabbox-exit-node-ip
else
printf '%s\n' "curl is not installed for exit-node egress check" >&2
exit 87
fi
test -s /tmp/crabbox-exit-node-ip
`
}