Skip to content
Merged
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
11 changes: 7 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
branches: ["main"]
pull_request:

permissions:
contents: read

jobs:
test-and-build:
runs-on: ubuntu-latest
Expand All @@ -14,11 +17,11 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
go-version: "1.25.6"

- name: Verify formatting
run: |
unformatted="$(gofmt -l $(find . -name '*.go' -type f))"
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "Unformatted files:"
echo "$unformatted"
Expand Down Expand Up @@ -47,7 +50,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
go-version: "1.25.6"

- name: Build Linux CLI binary
run: |
Expand All @@ -68,7 +71,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
go-version: "1.25.6"

- name: Build Windows CLI binary
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
go-version: "1.25.6"

- name: Build artifacts
run: |
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Thanks for your interest in contributing. This guide covers the basics.

## Development Setup

1. **Go 1.22+** is required.
1. **Go 1.25.6+** is required.
2. Clone the repository and build:
```bash
go build ./...
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ if you need to get a field machine onto your tailnet without building a backend
- **elevated privileges** are required for enrollment and cleanup (`sudo` on linux, administrator on windows).
- **linux**: debian or ubuntu with `systemd`. other distributions are not supported in the current release.
- **windows**: standard windows with scheduled tasks available.
- **go 1.22+** is required for building from source.
- **go 1.25.6+** is required for building from source.

## quickstart

Expand Down Expand Up @@ -64,7 +64,7 @@ release binaries are built for `linux/amd64`, `linux/arm64`, `windows/amd64`, an
| `--preset` | `""` | preset id from config |
| `--mode` | `session` | lease mode: `session`, `timed`, or `permanent` |
| `--channel` | `stable` | install channel: `stable` or `latest` |
| `--days` | `3` | timed lease duration in days |
| `--days` | `3` | timed lease duration in days (`1`, `3`, or `7`) |
| `--custom-days` | `0` | custom lease days (1–30, overrides `--days`) |
| `--suffix` | `""` | optional device name suffix |
| `--exit-node` | `""` | optional approved exit node |
Expand All @@ -73,7 +73,7 @@ release binaries are built for `linux/amd64`, `linux/arm64`, `windows/amd64`, an
| `--password` | `""` | operator password (or set `TAILSTICK_OPERATOR_PASSWORD`) |
| `--config` | `tailstick.config.json` | config file path |
| `--state` | platform default | state file path |
| `--audit` | `logs/tailstick-audit.ndjson` | audit log path |
| `--audit` | `logs/tailstick-audit.ndjson` | audit log path (relative to config directory) |
| `--log` | platform default | log file path |
| `--dry-run` | `false` | print commands without executing |

Expand Down Expand Up @@ -104,6 +104,8 @@ the gui binary (`tailstick-linux-gui` / `tailstick-windows-gui`) starts a local
| `--port` | `0` | bind port (`0` picks an ephemeral port) |
| `--open-browser` | `true` | open browser automatically |

`--non-interactive` applies to `tailstick run` only.

## lease modes

| mode | behavior |
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

TailStick config is JSON.

Example: [configs/tailstick.config.example.json](/home/ubuntu/workspace/tailscale-usb/configs/tailstick.config.example.json)
Example: [configs/tailstick.config.example.json](../configs/tailstick.config.example.json)

## Top-Level

Expand Down
14 changes: 9 additions & 5 deletions docs/release-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,14 @@ git push origin v1.0.0

Tag push triggers the release workflow which builds and uploads:

- `tailstick-linux-amd64.tar.gz`
- `tailstick-linux-arm64.tar.gz`
- `tailstick-windows-amd64.tar.gz`
- `tailstick-windows-arm64.tar.gz`
- `tailstick-cli-linux-amd64`
- `tailstick-gui-linux-amd64`
- `tailstick-cli-linux-arm64`
- `tailstick-gui-linux-arm64`
- `tailstick-cli-windows-amd64.exe`
- `tailstick-gui-windows-amd64.exe`
- `tailstick-cli-windows-arm64.exe`
- `tailstick-gui-windows-arm64.exe`

## Verify Release

Expand All @@ -68,7 +72,7 @@ gh release view v1.0.0 -R Microck/tailstick --json assets,url
```

3. Optional post-release smoke:
- Download one Linux and one Windows archive from the release and verify expected binaries are present.
- Download one Linux and one Windows binary and verify basic execution (`tailstick version`).

## Rollback

Expand Down
35 changes: 29 additions & 6 deletions internal/gui/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"embed"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
Expand Down Expand Up @@ -62,6 +63,8 @@ var validChannels = map[string]bool{
string(model.ChannelLatest): true,
}

const maxEnrollRequestBytes = 64 * 1024

func Run(ctx context.Context, srv *Server, openBrowser bool, host string, port int) error {
host = strings.TrimSpace(host)
if host == "" {
Expand Down Expand Up @@ -112,7 +115,10 @@ func (s *Server) presets(w http.ResponseWriter, r *http.Request) {
}
cfg, err := config.Load(s.ConfigPath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
if s.Logf != nil {
s.Logf("load presets config: %v", err)
}
http.Error(w, "failed to load presets", http.StatusInternalServerError)
return
}
summaries := make([]presetSummary, len(cfg.Presets))
Expand All @@ -134,8 +140,15 @@ func (s *Server) enroll(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxEnrollRequestBytes)
var req enrollRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
dec := json.NewDecoder(io.LimitReader(r.Body, maxEnrollRequestBytes))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
http.Error(w, "invalid json body", http.StatusBadRequest)
return
}
Expand All @@ -155,6 +168,10 @@ func (s *Server) enroll(w http.ResponseWriter, r *http.Request) {
writeJSONCode(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "customDays must be non-negative"})
return
}
if req.Mode == string(model.LeaseModeTimed) && req.CustomDays == 0 && req.Days == 0 {
writeJSONCode(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "timed mode requires days > 0 or customDays > 0"})
return
}
password := strings.TrimSpace(req.Password)
if password == "" {
password = strings.TrimSpace(os.Getenv("TAILSTICK_OPERATOR_PASSWORD"))
Expand All @@ -171,16 +188,22 @@ func (s *Server) enroll(w http.ResponseWriter, r *http.Request) {
Password: password,
})
if err != nil {
writeJSONCode(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
if s.Logf != nil {
s.Logf("enroll request failed: %v", err)
}
writeJSONCode(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "enrollment failed"})
return
}
writeJSON(w, map[string]any{
response := map[string]any{
"ok": true,
"leaseId": rec.LeaseID,
"deviceName": rec.DeviceName,
"mode": rec.Mode,
"expiresAt": rec.ExpiresAt,
})
}
if rec.ExpiresAt != nil {
response["expiresAt"] = rec.ExpiresAt
}
writeJSON(w, response)
}

func (s *Server) index(w http.ResponseWriter, r *http.Request) {
Expand Down
50 changes: 50 additions & 0 deletions internal/gui/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -112,6 +113,11 @@ func TestEnrollRejectsInvalidModeAndNegativeDurations(t *testing.T) {
body: `{"mode":"timed","channel":"stable","customDays":-1}`,
want: `customDays must be non-negative`,
},
{
name: "timed mode with zero days",
body: `{"mode":"timed","channel":"stable","days":0,"customDays":0}`,
want: `timed mode requires days > 0`,
},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/enroll", bytes.NewBufferString(tc.body))
Expand All @@ -130,3 +136,47 @@ func TestEnrollRejectsInvalidModeAndNegativeDurations(t *testing.T) {
})
}
}

func TestPresetsLoadErrorDoesNotLeakInternalPath(t *testing.T) {
srv := &Server{ConfigPath: "/path/that/does/not/exist"}
req := httptest.NewRequest(http.MethodGet, "/api/presets", nil)
rec := httptest.NewRecorder()
srv.presets(rec, req)

if rec.Code != http.StatusInternalServerError {
t.Fatalf("got status %d want 500", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "failed to load presets") {
t.Fatalf("got body %q want generic presets error", body)
}
if strings.Contains(body, "/path/that/does/not/exist") {
t.Fatalf("response leaked config path: %q", body)
}
}

func TestEnrollFailureReturnsGenericError(t *testing.T) {
srv := &Server{
EnrollFn: func(context.Context, model.RuntimeOptions) (model.LeaseRecord, error) {
return model.LeaseRecord{}, fmt.Errorf("secret details should not be returned")
},
}
req := httptest.NewRequest(http.MethodPost, "/api/enroll", bytes.NewBufferString(`{"mode":"session","channel":"stable"}`))
rec := httptest.NewRecorder()
srv.enroll(rec, req)

if rec.Code != http.StatusBadRequest {
t.Fatalf("got status %d want 400", rec.Code)
}
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
got, _ := payload["error"].(string)
if got != "enrollment failed" {
t.Fatalf("got error %q want enrollment failed", got)
}
if strings.Contains(rec.Body.String(), "secret details") {
t.Fatalf("response leaked internal details: %q", rec.Body.String())
}
}
12 changes: 6 additions & 6 deletions internal/tailscale/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func (c Client) Status(ctx context.Context) (model.TailscaleStatus, error) {
}
selfRaw, ok := root["Self"].(map[string]any)
if !ok {
return model.TailscaleStatus{}, fmt.Errorf("status json missing Self object")
return model.TailscaleStatus{}, fmt.Errorf("parse tailscale status: response JSON missing required Self object")
}
var st model.TailscaleStatus
if v, ok := selfRaw["ID"].(string); ok {
Expand Down Expand Up @@ -178,15 +178,15 @@ func authKeyArg(auth string) (string, func(), error) {
cleanup := func() {
_ = os.Remove(path)
}
if _, err := f.WriteString(auth); err != nil {
if err := f.Chmod(0o600); err != nil && runtime.GOOS != "windows" {
cleanup()
_ = f.Close()
return "", func() {}, fmt.Errorf("write auth key temp file: %w", err)
return "", func() {}, fmt.Errorf("chmod auth key temp file: %w", err)
}
if err := f.Chmod(0o600); err != nil && runtime.GOOS != "windows" {
if _, err := f.WriteString(auth); err != nil {
cleanup()
_ = f.Close()
return "", func() {}, fmt.Errorf("chmod auth key temp file: %w", err)
return "", func() {}, fmt.Errorf("write auth key temp file: %w", err)
}
if err := f.Close(); err != nil {
cleanup()
Expand Down Expand Up @@ -255,7 +255,7 @@ func ParseDurationDays(mode model.LeaseMode, defaultDays, customDays int) (int,
return defaultDays, nil
}
}
return 0, fmt.Errorf("timed lease requires days in {1,3,7} or custom-days in [1,30]")
return 0, fmt.Errorf("timed lease requires days in {1,3,7} or custom-days in [1,30]; got %d", defaultDays)
default:
return 0, fmt.Errorf("invalid lease mode %s", mode)
}
Expand Down
61 changes: 61 additions & 0 deletions internal/tailscale/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -127,6 +128,66 @@ done
}
}

func TestAuthKeyArgCreatesRestrictedTempFile(t *testing.T) {
arg, cleanup, err := authKeyArg("tskey-auth-secret")
if err != nil {
t.Fatalf("authKeyArg: %v", err)
}
defer cleanup()

const prefix = "--auth-key=file:"
if !strings.HasPrefix(arg, prefix) {
t.Fatalf("got arg %q want prefix %q", arg, prefix)
}
path := strings.TrimPrefix(arg, prefix)
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat temp file: %v", err)
}
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
t.Fatalf("got file mode %o want 600", info.Mode().Perm())
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read temp file: %v", err)
}
if string(body) != "tskey-auth-secret" {
t.Fatalf("got auth key body %q", string(body))
}
}

func TestParseDurationDaysIncludesInvalidValue(t *testing.T) {
_, err := ParseDurationDays(model.LeaseModeTimed, 2, 0)
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "got 2") {
t.Fatalf("got error %q want invalid value context", err)
}
}

func TestStatusFallbackErrorIncludesParseContext(t *testing.T) {
root := t.TempDir()
scriptPath := filepath.Join(root, "tailscale")
script := `#!/bin/sh
set -eu
echo '{"Self":"bad-shape"}'
`
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatalf("write fake tailscale: %v", err)
}
t.Setenv("PATH", root+string(os.PathListSeparator)+os.Getenv("PATH"))

client := Client{Runner: platform.Runner{}}
_, err := client.Status(context.Background())
if err == nil {
t.Fatal("expected status parse error")
}
if !strings.Contains(err.Error(), "parse tailscale status") {
t.Fatalf("got error %q want parse context", err)
}
}

type roundTripFunc func(*http.Request) (*http.Response, error)

func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
Expand Down
Loading