From 46a40d3016e64c66edd579b30d952cc5f4e1a770 Mon Sep 17 00:00:00 2001 From: Rob <178471500+rob-htl@users.noreply.github.com> Date: Fri, 21 Feb 2025 11:11:30 +1300 Subject: [PATCH] Add zerotier-wrapper Changes: * Add zerotier-wrapper as extension entrypoint, this wrapper: * handles PID cleanup and creation * works instead of the upstream zerotier symlinks for zerotier-cli and zerotier-idtool * takes configuration from ENV vars ZEROTIER_NETWORK and optionally ZEROTIER_IDENTITY_SECRET * ZEROTIER_NETWORK must be set, this is the ID of the network that zerotier attempts to join after start up (typically a manual process) * If ZEROTIER_IDENTITY_SECRET is optionally set this is written out and used by zerotier to authenticate as the node * If ZEROTIER_IDENTITY_SECRET is not set a new identity is created * logs the various lifecycle steps in a verbose way * Remove unused mounts (zerotier is compiled statically so /lib etc aren't needed) * Removes the aforementioned symlinks for zerotier-cli and zerotier-idtool as they aren't relevant without shell * Adds some usage docs in network/zerotier/README.md * Adds the renovate comment for zerotier version --- network/vars.yaml | 2 +- network/zerotier/README.md | 60 ++++++ network/zerotier/pkg.yaml | 7 +- network/zerotier/zerotier-wrapper/go.mod | 5 + network/zerotier/zerotier-wrapper/go.sum | 2 + network/zerotier/zerotier-wrapper/main.go | 232 +++++++++++++++++++++ network/zerotier/zerotier-wrapper/pkg.yaml | 24 +++ network/zerotier/zerotier.yaml | 32 +-- 8 files changed, 334 insertions(+), 30 deletions(-) create mode 100644 network/zerotier/README.md create mode 100644 network/zerotier/zerotier-wrapper/go.mod create mode 100644 network/zerotier/zerotier-wrapper/go.sum create mode 100644 network/zerotier/zerotier-wrapper/main.go create mode 100644 network/zerotier/zerotier-wrapper/pkg.yaml diff --git a/network/vars.yaml b/network/vars.yaml index 7f3ec613..047fd6c3 100644 --- a/network/vars.yaml +++ b/network/vars.yaml @@ -6,5 +6,5 @@ LLDPD_VERSION: 1.0.19 CLOUDFLARED_VERSION: 2024.12.1 # renovate: datasource=github-releases extractVersion=^v(?.*)$ depName=slackhq/nebula NEBULA_VERSION: 1.9.5 - +# renovate: datasource=github-releases depName=zerotier/ZeroTierOne ZEROTIER_VERSION: 1.14.2 diff --git a/network/zerotier/README.md b/network/zerotier/README.md new file mode 100644 index 00000000..b2aea511 --- /dev/null +++ b/network/zerotier/README.md @@ -0,0 +1,60 @@ +# ZeroTier + +Adds https://zerotier.com network interfaces as system extensions. +This means you can access your Talos nodes from machines you have configured +with ZeroTier, creating a secure overlay network. + +## Installation + +See [Installing Extensions](https://github.com/siderolabs/extensions#installing-extensions). + +## Usage + +Configure the extension via `ExtensionServiceConfig` document. + +```yaml +--- +apiVersion: v1alpha1 +kind: ExtensionServiceConfig +name: zerotier +environment: + - ZEROTIER_NETWORK= +``` + +Then apply the patch to your node's MachineConfigs + +```bash +talosctl patch mc -p @zerotier-config.yaml +``` + +You can then verify that it is in place with the following command + +```bash +talosctl get extensionserviceconfigs + +NODE NAMESPACE TYPE ID VERSION +mynode runtime ExtensionServiceConfig zerotier 1 +``` + +## Configuration + +The extension can be configured through environment variables: + +- `ZEROTIER_NETWORK`: The network ID to join (required) +- `ZEROTIER_IDENTITY_SECRET`: Optional pre-existing identity to use (format: "address:0:public:private") + +### Using an existing identity + +If you want to maintain the same ZeroTier identity across rebuilds or different nodes, you can specify an existing identity: + +```yaml +--- +apiVersion: v1alpha1 +kind: ExtensionServiceConfig +name: zerotier +environment: + - ZEROTIER_NETWORK= + - ZEROTIER_IDENTITY_SECRET= +``` + +If no identity is provided, a new one will be generated automatically. (You may need to authorize this node in your Zerotier network according to your network policies before it will recieve an IP address). diff --git a/network/zerotier/pkg.yaml b/network/zerotier/pkg.yaml index 17308bba..2ee315f8 100644 --- a/network/zerotier/pkg.yaml +++ b/network/zerotier/pkg.yaml @@ -3,6 +3,7 @@ variant: alpine shell: /toolchain/bin/bash dependencies: - stage: base + - stage: zerotier-wrapper install: - libstdc++ steps: @@ -28,10 +29,8 @@ steps: - | mkdir -p /rootfs/usr/local/lib/containers/zerotier/usr/local/bin/ cp -pr zerotier-one /rootfs/usr/local/lib/containers/zerotier/usr/local/bin/ + cp -pr /rootfs/usr/local/bin/zerotier-wrapper /rootfs/usr/local/lib/containers/zerotier/usr/local/bin/ chmod +x /rootfs/usr/local/lib/containers/zerotier/usr/local/bin/zerotier-* - cd /rootfs/usr/local/lib/containers/zerotier/usr/local/bin - ln -sf zerotier-one zerotier-cli - ln -sf zerotier-one zerotier-idtool - | mkdir -p /rootfs/usr/local/etc/containers/zerotier/usr/local/etc/zerotier/state cp /pkg/zerotier.yaml /rootfs/usr/local/etc/containers/ @@ -42,7 +41,7 @@ steps: cp /pkg/manifest.yaml /extensions-validator-rootfs/manifest.yaml /extensions-validator validate --rootfs=/extensions-validator-rootfs --pkg-name="${PKG_NAME}" - | - [[ $(/rootfs/usr/local/lib/containers/zerotier/usr/local/bin/zerotier-cli -v) == *{{ .ZEROTIER_VERSION }}* ]] + [[ $(/rootfs/usr/local/lib/containers/zerotier/usr/local/bin/zerotier-one -v) == *{{ .ZEROTIER_VERSION }}* ]] finalize: - from: /rootfs to: /rootfs diff --git a/network/zerotier/zerotier-wrapper/go.mod b/network/zerotier/zerotier-wrapper/go.mod new file mode 100644 index 00000000..4821dca4 --- /dev/null +++ b/network/zerotier/zerotier-wrapper/go.mod @@ -0,0 +1,5 @@ +module zerotier-wrapper + +go 1.23.0 + +require golang.org/x/sys v0.30.0 diff --git a/network/zerotier/zerotier-wrapper/go.sum b/network/zerotier/zerotier-wrapper/go.sum new file mode 100644 index 00000000..241f4caf --- /dev/null +++ b/network/zerotier/zerotier-wrapper/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/network/zerotier/zerotier-wrapper/main.go b/network/zerotier/zerotier-wrapper/main.go new file mode 100644 index 00000000..6ef0b54a --- /dev/null +++ b/network/zerotier/zerotier-wrapper/main.go @@ -0,0 +1,232 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "log" + "os" + "os/exec" + "os/signal" + "strconv" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +const ( + zerotierPath = "/var/lib/zerotier-one" + identityPath = "/var/lib/zerotier-one/identity.secret" + identityPubPath = "/var/lib/zerotier-one/identity.public" + pidFile = "/var/lib/zerotier-one/zerotier-one.pid" + zerotierBinPath = "/usr/local/bin/zerotier-one" +) + +func main() { + log.Printf("zerotier-wrapper: initializing...") + + // Ensure the ZeroTier state directory exists. + if err := os.MkdirAll(zerotierPath, 0755); err != nil { + log.Fatalf("failed to create state directory: %v", err) + } + + // Ensure identity configuration. + identitySource, err := ensureIdentity() + if err != nil { + log.Fatalf("identity configuration failed: %v", err) + } + log.Printf("identity configured (source: %s)", identitySource) + + // Cleanup any existing zerotier-one process. + if err := cleanupProcess(); err != nil { + log.Fatalf("process cleanup failed: %v", err) + } + + // If ZEROTIER_NETWORK env var is set, join network using zerotier-one -q. + if network := os.Getenv("ZEROTIER_NETWORK"); network != "" { + log.Printf("will join network %s after startup", network) + go func() { + time.Sleep(2 * time.Second) + if err := joinNetwork(network); err != nil { + log.Printf("failed to join network: %v", err) + } else { + log.Printf("joined network %s", network) + } + }() + } + + // Start zerotier-one process. + cmd := exec.Command(zerotierBinPath, "-U", zerotierPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + log.Fatalf("error starting zerotier-one: %v", err) + } + + // Write the PID file. + pidStr := strconv.Itoa(cmd.Process.Pid) + if err := os.WriteFile(pidFile, []byte(pidStr), 0644); err != nil { + log.Printf("failed to write PID file: %v", err) + } + + // Forward termination signals to the zerotier-one process. + ch := make(chan os.Signal, 1) + signal.Notify(ch, unix.SIGINT, unix.SIGTERM) + sig := <-ch + log.Printf("received signal %v, forwarding to zerotier-one process", sig) + if err := cmd.Process.Signal(sig); err != nil { + log.Fatalf("error sending signal to zerotier-one: %v", err) + } + + if err := cmd.Wait(); err != nil { + log.Fatalf("zerotier-one exited with error: %v", err) + } +} + +// ensureIdentity checks for an existing identity file, validates it if found, +// or else uses the identity from the ZEROTIER_IDENTITY_SECRET environment variable (after validation) +// or generates a new one using "zerotier-one -i generate". +func ensureIdentity() (string, error) { + // If the identity file exists, validate its contents. + if _, err := os.Stat(identityPath); err == nil { + data, err := os.ReadFile(identityPath) + if err != nil { + return "", fmt.Errorf("failed to read existing identity: %w", err) + } + identity := strings.TrimSpace(string(data)) + log.Printf("found existing identity at %s, validating...", identityPath) + if err := validateIdentity(identity); err != nil { + return "", fmt.Errorf("existing identity failed validation: %w", err) + } + log.Printf("existing identity validated") + return "existing", nil + } else if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("failed to stat identity file: %w", err) + } + + // Check for identity in environment. + if identity := os.Getenv("ZEROTIER_IDENTITY_SECRET"); identity != "" { + log.Printf("found identity in ZEROTIER_IDENTITY_SECRET environment variable, validating...") + if err := validateIdentity(identity); err != nil { + return "", fmt.Errorf("environment identity invalid: %w", err) + } + log.Printf("environment identity validated") + if err := writeIdentity(identity); err != nil { + return "", fmt.Errorf("failed to write identity from environment: %w", err) + } + return "environment", nil + } + + // Generate a new identity using "zerotier-one -i generate". + log.Printf("generating new identity using zerotier-one -i generate") + cmd := exec.Command(zerotierBinPath, "-i", "generate") + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("failed to generate identity: %w", err) + } + identity := strings.TrimSpace(out.String()) + if err := validateIdentity(identity); err != nil { + return "", fmt.Errorf("generated identity failed validation: %w", err) + } + if err := writeIdentity(identity); err != nil { + return "", fmt.Errorf("failed to write generated identity: %w", err) + } + return "generated", nil +} + +// validateIdentity runs "zerotier-one -i validate " to ensure the identity is valid. +func validateIdentity(identity string) error { + cmd := exec.Command(zerotierBinPath, "-i", "validate", identity) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("identity validation failed: %w", err) + } + return nil +} + +// writeIdentity writes the complete identity string (all four parts) to identity.secret, +// while writing only the first three parts (separated by ':') to identity.public. +func writeIdentity(identity string) error { + parts := strings.Split(identity, ":") + if len(parts) != 4 { + return fmt.Errorf("invalid identity format: expected 4 parts, got %d", len(parts)) + } + + // Write the secret identity file with the full identity. + if err := os.WriteFile(identityPath, []byte(identity), 0600); err != nil { + return fmt.Errorf("failed to write secret identity: %w", err) + } + log.Printf("wrote secret identity to %s", identityPath) + + // Write the public identity file with only the first 3 parts. + public := strings.Join(parts[:3], ":") + if err := os.WriteFile(identityPubPath, []byte(public), 0644); err != nil { + return fmt.Errorf("failed to write public identity: %w", err) + } + log.Printf("wrote public identity to %s", identityPubPath) + + return nil +} + +// cleanupProcess checks for an existing PID file; if found, it kills the process and removes the file. +func cleanupProcess() error { + if _, err := os.Stat(pidFile); err == nil { + pid, err := getProcessId() + if err != nil { + return fmt.Errorf("error reading pid file: %w", err) + } + if err := killProcess(pid); err != nil { + return fmt.Errorf("error killing process: %w", err) + } + if err := os.Remove(pidFile); err != nil { + return fmt.Errorf("error removing pid file: %w", err) + } + log.Printf("cleaned up existing process (PID %d)", pid) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to stat pid file: %w", err) + } else { + log.Printf("no PID file found, no existing process to clean up") + } + return nil +} + +func getProcessId() (int, error) { + pidData, err := os.ReadFile(pidFile) + if err != nil { + return 0, err + } + pidData = bytes.TrimRight(pidData, "\n") + pid, err := strconv.Atoi(string(pidData)) + if err != nil { + return 0, err + } + return pid, nil +} + +func killProcess(pid int) error { + p, err := os.FindProcess(pid) + if err != nil { + return err + } + if err := p.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + return err + } + return nil +} + +// joinNetwork uses "zerotier-one -q join " to join the specified network. +func joinNetwork(network string) error { + log.Printf("attempting to join network %s", network) + cmd := exec.Command(zerotierBinPath, "-q", "join", network) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("join network failed: %w", err) + } + return nil +} diff --git a/network/zerotier/zerotier-wrapper/pkg.yaml b/network/zerotier/zerotier-wrapper/pkg.yaml new file mode 100644 index 00000000..51d8560d --- /dev/null +++ b/network/zerotier/zerotier-wrapper/pkg.yaml @@ -0,0 +1,24 @@ +name: zerotier-wrapper +variant: scratch +shell: /toolchain/bin/bash +dependencies: +- stage: base +steps: +- cachePaths: + - /.cache/go-build + - /go/pkg + build: + - | + export PATH=${PATH}:${TOOLCHAIN}/go/bin + + cp -r /pkg/* . + + CGO_ENABLED=0 go build -o zerotier-wrapper main.go + install: + - | + mkdir -p /rootfs/usr/local/bin + + cp zerotier-wrapper /rootfs/usr/local/bin/zerotier-wrapper +finalize: +- from: /rootfs + to: /rootfs diff --git a/network/zerotier/zerotier.yaml b/network/zerotier/zerotier.yaml index 0c75ddbd..83341810 100644 --- a/network/zerotier/zerotier.yaml +++ b/network/zerotier/zerotier.yaml @@ -7,48 +7,30 @@ depends: - etcfiles - configuration: true container: - entrypoint: /usr/local/bin/zerotier-one - args: - - /var/lib/zerotier-one - environment: - - PATH=/sbin:/usr/local/bin + entrypoint: /usr/local/bin/zerotier-wrapper security: writeableRootfs: false writeableSysfs: true mounts: - # libs - - source: /lib - destination: /lib + # Zerotier binaries + - source: /usr/local/lib/containers/zerotier/usr/local/bin + destination: /usr/local/bin type: bind options: - bind - ro - # more libs - - source: /usr/lib - destination: /usr/lib - type: bind - options: - - bind - - ro - ## Required for zerotier. Ip addr and other commands - - source: /sbin - destination: /sbin - type: bind - options: - - bind - - ro - ## Zerotier needs to write to this to create the interfaces + # Zerotier needs to write to this to create the interfaces - source: /dev/net/tun destination: /dev/net/tun type: bind options: - bind - rw - ## Zerotier state. + # Zerotier state - source: /var/lib/zerotier-one/ destination: /var/lib/zerotier-one/ type: bind options: - bind - rw -restart: always \ No newline at end of file +restart: always