Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/nix.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
50 changes: 46 additions & 4 deletions cmd/tclipd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -667,7 +703,7 @@ WHERE p.id = ?1`
RawHTML *template.HTML
CSSClass string
EnableLineNumbers string
EnableWordWrap string
EnableWordWrap string
}{
UserInfo: up,
Title: fname,
Expand All @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
86 changes: 86 additions & 0 deletions cmd/tclipd/main_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}