diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09894fa..389b90b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: ["main"] pull_request: +permissions: + contents: read + jobs: test-and-build: runs-on: ubuntu-latest @@ -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" @@ -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: | @@ -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: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6148923..2caa5b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c3eff1b..6d25604 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 ./... diff --git a/README.md b/README.md index a9b11a6..c3271d1 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | @@ -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 | @@ -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 | diff --git a/docs/configuration.md b/docs/configuration.md index b91add7..3ffaae1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/docs/release-runbook.md b/docs/release-runbook.md index fddfc79..0b02b25 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -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 @@ -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 diff --git a/internal/gui/server.go b/internal/gui/server.go index 0a11573..af22c78 100644 --- a/internal/gui/server.go +++ b/internal/gui/server.go @@ -8,6 +8,7 @@ import ( "embed" "encoding/json" "fmt" + "io" "net" "net/http" "os" @@ -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 == "" { @@ -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)) @@ -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 } @@ -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")) @@ -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) { diff --git a/internal/gui/server_test.go b/internal/gui/server_test.go index 8821403..1f628c9 100644 --- a/internal/gui/server_test.go +++ b/internal/gui/server_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -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)) @@ -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()) + } +} diff --git a/internal/tailscale/client.go b/internal/tailscale/client.go index 288eb8f..3365e65 100644 --- a/internal/tailscale/client.go +++ b/internal/tailscale/client.go @@ -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 { @@ -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() @@ -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) } diff --git a/internal/tailscale/client_test.go b/internal/tailscale/client_test.go index 8e60522..3039d94 100644 --- a/internal/tailscale/client_test.go +++ b/internal/tailscale/client_test.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "strings" "testing" @@ -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) {