From 0ac672f131132135a97e085d8501252ca3e606e0 Mon Sep 17 00:00:00 2001 From: Ryan Draves Date: Mon, 21 Sep 2026 10:24:06 -0600 Subject: [PATCH] cmd/tclipd: support reading TS_AUTHKEY from a file The published image is based on gcr.io/distroless/static and so has no shell, and tsnet reads TS_AUTHKEY directly from the environment. Together these mean there is no way to supply the authkey as a Docker or Kubernetes secret, which is delivered as a mounted file: the usual trick of reading the file in an entrypoint needs a shell that isn't there. Resolve the "file:" prefix already used by the Tailscale container images, so TS_AUTHKEY=file:/run/secrets/tclip_authkey reads the key from that path. Surrounding whitespace is trimmed, since secret files conventionally end in a newline. A missing or empty file is fatal rather than falling through to an interactive login the operator would never see. Co-Authored-By: Claude Opus 5 --- .github/workflows/nix.yaml | 3 ++ README.md | 34 +++++++++++++++ cmd/tclipd/main.go | 50 ++++++++++++++++++++-- cmd/tclipd/main_test.go | 86 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 cmd/tclipd/main_test.go diff --git a/.github/workflows/nix.yaml b/.github/workflows/nix.yaml index 1d7f12d..f3598c6 100644 --- a/.github/workflows/nix.yaml +++ b/.github/workflows/nix.yaml @@ -18,6 +18,9 @@ jobs: - name: "Basic CLI and web build" run: | nix build .#tclip .#tclipd + - name: "Go tests" + run: | + nix develop --command -- go test ./... - name: "Docker image build (dry run)" run: | nix develop --command -- mkctr --gopaths="./cmd/tclipd:/bin/tclipd" --tags="latest" --base="gcr.io/distroless/static" --repos=ghcr.io/tailscale-dev/tclip --ldflags="-w -s" -- /bin/tclipd diff --git a/README.md b/README.md index 13f54a6..7b4d72e 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,14 @@ will destroy the node when the service shuts down. Your authkey should start with `tskey-auth`. +The authkey is read from the `TS_AUTHKEY` environment variable. If its value +begins with `file:`, the rest is treated as the path of a file containing the +key, as in `TS_AUTHKEY=file:/run/secrets/tclip_authkey`. tclip exits with an +error if the file is missing or empty. + +The key is only needed to register the node. Once registered, the node's +identity lives in the data directory, so the key can be removed. + You will need to have Magic DNS enabled. ### fly.io @@ -144,6 +152,32 @@ docker rm -f tclip Then run the above command to recreate the container. +#### Docker Compose + +To keep the authkey out of the environment, pass it as a secret and point +`TS_AUTHKEY` at the file Compose mounts it to: + +```yaml +services: + tclip: + image: ghcr.io/tailscale-dev/tclip:latest + environment: + DATA_DIR: /data + TS_AUTHKEY: file:/run/secrets/tclip_authkey + volumes: + - tclip-data:/data + secrets: + - tclip_authkey + restart: always + +volumes: + tclip-data: + +secrets: + tclip_authkey: + file: ./tclip.authkey +``` + #### Backups Add the path `/var/lib/tclip` to your backup program of choice. diff --git a/cmd/tclipd/main.go b/cmd/tclipd/main.go index ed1ec36..18f402a 100644 --- a/cmd/tclipd/main.go +++ b/cmd/tclipd/main.go @@ -83,6 +83,42 @@ func envOr(key, defaultVal string) string { return defaultVal } +// authKeyPrefix marks a TS_AUTHKEY value as a path to read the key from +// rather than the key itself. This matches the convention used by the +// Tailscale container images. +const authKeyPrefix = "file:" + +// authKeyFromEnv returns the Tailscale auth key configured in the +// environment, reading TS_AUTHKEY. +// +// If the value begins with "file:", the remainder is the path to a file +// holding the key, and that file's contents are returned instead. +// +// An empty return value means no key was configured, in which case tsnet +// prints a login URL instead. +func authKeyFromEnv() (string, error) { + key := os.Getenv("TS_AUTHKEY") + + path, ok := strings.CutPrefix(key, authKeyPrefix) + if !ok { + return key, nil + } + if path == "" { + return "", fmt.Errorf("auth key %q names no file", authKeyPrefix) + } + + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("reading auth key file: %w", err) + } + // Trim whitespace from the file contents + key = strings.TrimSpace(string(data)) + if key == "" { + return "", fmt.Errorf("auth key file %q is empty", path) + } + return key, nil +} + type Server struct { lc *tailscale.LocalClient // localclient to tsnet server db *sql.DB // SQLite datastore @@ -667,7 +703,7 @@ WHERE p.id = ?1` RawHTML *template.HTML CSSClass string EnableLineNumbers string - EnableWordWrap string + EnableWordWrap string }{ UserInfo: up, Title: fname, @@ -682,7 +718,7 @@ WHERE p.id = ?1` RawHTML: rawHTML, CSSClass: cssClass, EnableLineNumbers: lineNumbersClass, - EnableWordWrap: wordWrapClass, + EnableWordWrap: wordWrapClass, }) if err != nil { log.Printf("%s: %v", r.RemoteAddr, err) @@ -695,11 +731,17 @@ func main() { os.MkdirAll(*dataDir, 0700) os.MkdirAll(filepath.Join(*dataDir, "tsnet"), 0700) + authKey, err := authKeyFromEnv() + if err != nil { + log.Fatal(err) + } + s := &tsnet.Server{ Hostname: *hostname, Dir: filepath.Join(*dataDir, "tsnet"), Logf: func(string, ...any) {}, ControlURL: *controlUrl, + AuthKey: authKey, } if *tsnetLogVerbose { @@ -742,8 +784,8 @@ func main() { } // if the user disabled HTTPS or HTTPS is unavailable - if *disableHTTPS { - tclipURL = *hostname + if *disableHTTPS { + tclipURL = *hostname } ln, err := s.Listen("tcp", ":80") diff --git a/cmd/tclipd/main_test.go b/cmd/tclipd/main_test.go new file mode 100644 index 0000000..e23f9da --- /dev/null +++ b/cmd/tclipd/main_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAuthKeyFromEnv(t *testing.T) { + // writeKeyFile writes contents to a temp file and returns its path. + writeKeyFile := func(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "authkey") + if err := os.WriteFile(path, []byte(contents), 0600); err != nil { + t.Fatal(err) + } + return path + } + + t.Run("unset", func(t *testing.T) { + t.Setenv("TS_AUTHKEY", "") + + got, err := authKeyFromEnv() + if err != nil { + t.Fatalf("authKeyFromEnv() = %v", err) + } + if got != "" { + t.Errorf("got %q, want empty so tsnet falls back to interactive login", got) + } + }) + + t.Run("literal key", func(t *testing.T) { + t.Setenv("TS_AUTHKEY", "tskey-auth-literal") + + got, err := authKeyFromEnv() + if err != nil { + t.Fatalf("authKeyFromEnv() = %v", err) + } + if want := "tskey-auth-literal"; got != want { + t.Errorf("got %q, want %q", got, want) + } + }) + + t.Run("file", func(t *testing.T) { + t.Setenv("TS_AUTHKEY", "file:"+writeKeyFile(t, "tskey-auth-fromfile")) + + got, err := authKeyFromEnv() + if err != nil { + t.Fatalf("authKeyFromEnv() = %v", err) + } + if want := "tskey-auth-fromfile"; got != want { + t.Errorf("got %q, want %q", got, want) + } + }) + + t.Run("file with trailing newline", func(t *testing.T) { + t.Setenv("TS_AUTHKEY", "file:"+writeKeyFile(t, "tskey-auth-fromfile\n")) + + got, err := authKeyFromEnv() + if err != nil { + t.Fatalf("authKeyFromEnv() = %v", err) + } + if want := "tskey-auth-fromfile"; got != want { + t.Errorf("got %q, want %q", got, want) + } + }) + + // A misconfigured key file must be a hard error rather than silently + // falling through to an interactive login the operator will never see. + errorCases := map[string]string{ + "missing file": "file:" + filepath.Join(t.TempDir(), "does-not-exist"), + "empty file": "file:" + writeKeyFile(t, ""), + "blank file": "file:" + writeKeyFile(t, "\n\n"), + "no path": "file:", + } + for name, value := range errorCases { + t.Run(name, func(t *testing.T) { + t.Setenv("TS_AUTHKEY", value) + + got, err := authKeyFromEnv() + if err == nil { + t.Fatalf("authKeyFromEnv() = %q, want error", got) + } + }) + } +}