From 8058ae460c4f71e612ef629331dfd48bdc260cd0 Mon Sep 17 00:00:00 2001 From: Marvin von Rappard Date: Wed, 23 Sep 2026 23:04:14 +0200 Subject: [PATCH 1/2] build: armv7 image, release binaries, gosec, and an exec-form entrypoint - Publish the image for linux/arm/v7 alongside amd64 and arm64. The Go binary is cross-compiled on the build platform, so only the runtime stage runs under QEMU. - Build static Linux binaries (amd64, arm64, armv7) with GoReleaser and attach them, with checksums, to each published GitHub release. CI builds a snapshot so a broken config fails on the PR. - Enable gosec in golangci-lint. Byte counts from Docker and statfs now saturate instead of wrapping; the remaining findings are annotated with why they are safe. - ENTRYPOINT is now ["/app/docktail"]. The shell's `sleep 1` gave a starting tailscaled a head start; DockTail now waits for the socket itself (up to 15s) before the version check and the first reconcile. - Drop iptables/ip6tables from the runtime image. They came in with the Alpine tailscale package; the image only carries the tailscale CLI and runs no daemon. --- .dockerignore | 3 ++ .github/workflows/ci.yaml | 34 +++++++++++++++++- .github/workflows/docker-build.yaml | 18 ++++++++-- .github/workflows/release.yaml | 45 ++++++++++++++++++++++++ .gitignore | 3 ++ .golangci.yml | 1 + .goreleaser.yaml | 51 +++++++++++++++++++++++++++ Dockerfile | 25 +++++++++---- README.md | 2 ++ cloud/hostfs_linux.go | 20 ++++++++--- cloud/hostmetrics.go | 8 ++--- cloud/wsclient.go | 2 +- docker/cloud.go | 18 +++++++--- docs/02-installation.md | 22 ++++++++++++ docs/07-reference.md | 7 ++-- health/health.go | 2 +- main.go | 21 ++++++++++- tailscale/client.go | 2 +- tailscale/socketwatch.go | 39 +++++++++++++++++++++ tailscale/socketwatch_test.go | 54 +++++++++++++++++++++++++++++ tailscale/utils.go | 4 ++- tools/docsgen/main.go | 10 +++--- 22 files changed, 355 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/release.yaml create mode 100644 .goreleaser.yaml diff --git a/.dockerignore b/.dockerignore index 93ae9da..f79b938 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,6 +12,9 @@ docktail *.out coverage.txt +# GoReleaser output +dist/ + # IDE files .vscode/ .idea/ diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2f27fa9..dd9abe7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -61,12 +61,44 @@ jobs: platform: linux/amd64 - runner: ubuntu-24.04-arm platform: linux/arm64 + - runner: ubuntu-latest + platform: linux/arm/v7 steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up QEMU + if: matrix.platform == 'linux/arm/v7' + uses: docker/setup-qemu-action@v3 + with: + platforms: arm + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build Docker image - run: docker build . + run: docker buildx build --platform ${{ matrix.platform }} . + + # Builds the release archives the way release.yaml does, without + # publishing, so a broken .goreleaser.yaml fails here rather than on release. + release-snapshot: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Build release archives (snapshot) + uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: release --snapshot --clean --skip=publish # Regression test for issues #72 and #78: a replaced tailscaled socket directory # leaves DockTail's bind mount stale forever. Needs no tailnet credentials, so diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 8934edf..bc3b4be 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -25,11 +25,22 @@ jobs: - runner: ubuntu-24.04-arm platform: linux/arm64 arch: arm64 + # No 32-bit ARM runner: the Go binary is cross-compiled on the build + # platform, so QEMU only runs the runtime stage's package install. + - runner: ubuntu-latest + platform: linux/arm/v7 + arch: armv7 steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up QEMU + if: matrix.arch == 'armv7' + uses: docker/setup-qemu-action@v3 + with: + platforms: arm + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -85,8 +96,8 @@ jobs: labels: ${{ steps.meta.outputs.labels }} build-args: | VERSION=${{ github.ref_name }} - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=${{ matrix.arch }} manifest: needs: build @@ -133,5 +144,6 @@ jobs: for TAG in $TAGS; do docker buildx imagetools create -t $TAG \ $TAG-amd64 \ - $TAG-arm64 + $TAG-arm64 \ + $TAG-armv7 done diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..b3a8141 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,45 @@ +name: Release Binaries + +# Attaches static Linux binaries to a GitHub release once it is published. Tags +# alone only produce images (docker-build.yaml); binaries follow the releases +# the maintainer publishes, and the release's name and notes are left as written. +on: + release: + types: + - published + +jobs: + binaries: + runs-on: ubuntu-latest + permissions: + contents: write + env: + TAG: ${{ github.event.release.tag_name }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: refs/tags/${{ github.event.release.tag_name }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - name: Build release archives + uses: goreleaser/goreleaser-action@v6 + with: + version: '~> v2' + args: release --clean --skip=publish + env: + GORELEASER_CURRENT_TAG: ${{ github.event.release.tag_name }} + + - name: Attach archives to the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "$TAG" dist/*.tar.gz dist/checksums.txt \ + --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.gitignore b/.gitignore index d0cbc9f..8dee47e 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,6 @@ go.work.sum # Built Visual Studio Code Extensions *.vsix + +# GoReleaser output +dist/ diff --git a/.golangci.yml b/.golangci.yml index 4abfa9e..bae2143 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,4 +3,5 @@ version: "2" linters: default: standard enable: + - gosec - misspell diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..a398661 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,51 @@ +# Static release binaries, attached to each published GitHub release by +# .github/workflows/release.yaml. GoReleaser only builds and packages; the +# workflow uploads the archives to the release the maintainer published, so the +# release name and notes stay as written. +version: 2 + +project_name: docktail + +builds: + - id: docktail + main: . + binary: docktail + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + - arm64 + - arm + goarm: + - "7" + flags: + - -trimpath + # Same version stamp as the image (Dockerfile VERSION build arg = the tag); + # a snapshot build is stamped with its snapshot version instead. + ldflags: + - -s -w -X github.com/marvinvr/docktail/version.Version={{ if .IsSnapshot }}{{ .Version }}{{ else }}{{ .Tag }}{{ end }} + +archives: + - id: docktail + formats: + - tar.gz + # docktail_1.9.0_linux_amd64.tar.gz, …_linux_arm64, …_linux_armv7 + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }} + files: + - LICENSE + - README.md + +checksum: + name_template: checksums.txt + +snapshot: + version_template: "{{ incpatch .Version }}-snapshot" + +changelog: + disable: true + +release: + disable: true diff --git a/Dockerfile b/Dockerfile index 58d1160..1af7628 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,12 @@ -# Build stage -FROM golang:1.25-alpine AS builder +# Build stage. Runs on the build machine's own platform and cross-compiles for +# the target (CGO is off), so a multi-arch build only emulates the small +# runtime stage instead of the whole Go toolchain. +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder ARG VERSION=dev +ARG TARGETOS +ARG TARGETARCH +ARG TARGETVARIANT WORKDIR /build @@ -15,18 +20,22 @@ RUN go mod download # Copy source code COPY . . -# Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \ +# Build the application. TARGETVARIANT is "v7" for linux/arm/v7; GOARM wants +# the bare number. +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} \ + go build -a -installsuffix cgo \ -ldflags "-w -s -X github.com/marvinvr/docktail/version.Version=${VERSION}" \ -o docktail . # Tailscale binary stage — ensures CLI version matches the sidecar daemon exactly FROM tailscale/tailscale:latest AS tailscale -# Runtime stage +# Runtime stage. DockTail runs no tailscaled of its own; it only drives the +# host's or sidecar's daemon through the tailscale CLI over the mounted socket, +# so no packet-filtering tools are needed here. FROM alpine:latest -RUN apk add --no-cache ca-certificates iptables ip6tables +RUN apk add --no-cache ca-certificates # Copy tailscale CLI from official image to guarantee version consistency with sidecar COPY --from=tailscale /usr/local/bin/tailscale /usr/local/bin/tailscale @@ -41,4 +50,6 @@ COPY --from=builder /build/docktail . HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD ["/app/docktail", "health"] -ENTRYPOINT ["/bin/sh", "-c", "sleep 1 && exec /app/docktail"] +# Exec form: DockTail is PID 1 and receives SIGTERM directly. It waits for a +# tailscaled that is still starting on its own (see main.go socketStartupWait). +ENTRYPOINT ["/app/docktail"] diff --git a/README.md b/README.md index 77c6d71..2586014 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,8 @@ go build -o docktail . docker build -t docktail:latest . ``` +The image is published for `linux/amd64`, `linux/arm64` and `linux/arm/v7`, and each [release](https://github.com/marvinvr/docktail/releases) carries static Linux binaries for the same platforms; see [Platforms And Release Binaries](docs/02-installation.md#platforms-and-release-binaries). + ## Links - [Tailscale Services Documentation](https://tailscale.com/kb/1552/tailscale-services) diff --git a/cloud/hostfs_linux.go b/cloud/hostfs_linux.go index e9f7699..b37c14b 100644 --- a/cloud/hostfs_linux.go +++ b/cloud/hostfs_linux.go @@ -4,6 +4,7 @@ package cloud import ( "bufio" + "math" "os" "path/filepath" "sort" @@ -212,13 +213,13 @@ func statfsBytes(path string) (proto.Filesystem, bool) { if err := syscall.Statfs(path, &st); err != nil { return proto.Filesystem{}, false } - bs := uint64(st.Bsize) - if bs == 0 || uint64(st.Blocks) == 0 { + if st.Bsize <= 0 || uint64(st.Blocks) == 0 { return proto.Filesystem{}, false } - total := int64(uint64(st.Blocks) * bs) - free := int64(uint64(st.Bfree) * bs) - avail := int64(uint64(st.Bavail) * bs) + bs := uint64(st.Bsize) + total := saturatingInt64(uint64(st.Blocks) * bs) + free := saturatingInt64(uint64(st.Bfree) * bs) + avail := saturatingInt64(uint64(st.Bavail) * bs) if total <= 0 { return proto.Filesystem{}, false } @@ -232,6 +233,15 @@ func statfsBytes(path string) (proto.Filesystem, bool) { return proto.Filesystem{TotalBytes: total, UsedBytes: used, AvailBytes: avail}, true } +// saturatingInt64 converts a byte count to the int64 the report carries, +// capping it instead of wrapping to a negative value. +func saturatingInt64(u uint64) int64 { + if u > math.MaxInt64 { + return math.MaxInt64 + } + return int64(u) +} + // usedFraction is the `df` reading — used of what a normal user can still fill. func usedFraction(fs proto.Filesystem) float64 { if denom := fs.UsedBytes + fs.AvailBytes; denom > 0 { diff --git a/cloud/hostmetrics.go b/cloud/hostmetrics.go index e278add..866cf9f 100644 --- a/cloud/hostmetrics.go +++ b/cloud/hostmetrics.go @@ -336,7 +336,7 @@ func readThermalZones() []proto.TempReading { continue } label := filepath.Base(d) - if t, err := os.ReadFile(filepath.Join(d, "type")); err == nil { + if t, err := os.ReadFile(filepath.Join(d, "type")); err == nil { //nolint:gosec // G304: sysfs path globbed under sysDir if s := strings.TrimSpace(string(t)); s != "" { label = s } @@ -362,7 +362,7 @@ func readHwmonTemps() []proto.TempReading { // readMilliCelsius reads a sysfs millidegree-Celsius file and returns degrees C // rounded to one decimal, dropping implausible values. func readMilliCelsius(path string) (float64, bool) { - raw, err := os.ReadFile(path) + raw, err := os.ReadFile(path) //nolint:gosec // G304: sysfs path globbed under sysDir if err != nil { return 0, false } @@ -384,10 +384,10 @@ func hwmonLabel(inputPath string) string { dir := filepath.Dir(inputPath) prefix := strings.TrimSuffix(filepath.Base(inputPath), "_input") // tempX var chip, label string - if n, err := os.ReadFile(filepath.Join(dir, "name")); err == nil { + if n, err := os.ReadFile(filepath.Join(dir, "name")); err == nil { //nolint:gosec // G304: sysfs path globbed under sysDir chip = strings.TrimSpace(string(n)) } - if l, err := os.ReadFile(filepath.Join(dir, prefix+"_label")); err == nil { + if l, err := os.ReadFile(filepath.Join(dir, prefix+"_label")); err == nil { //nolint:gosec // G304: sysfs path globbed under sysDir label = strings.TrimSpace(string(l)) } switch { diff --git a/cloud/wsclient.go b/cloud/wsclient.go index 21cc85a..f00ff0a 100644 --- a/cloud/wsclient.go +++ b/cloud/wsclient.go @@ -269,7 +269,7 @@ type backoff struct { } func newBackoff() *backoff { - return &backoff{rng: rand.New(rand.NewSource(time.Now().UnixNano()))} + return &backoff{rng: rand.New(rand.NewSource(time.Now().UnixNano()))} //nolint:gosec // G404: reconnect jitter only spreads agents apart; it is not a secret } func (b *backoff) next() time.Duration { diff --git a/docker/cloud.go b/docker/cloud.go index 55185f9..775b9b6 100644 --- a/docker/cloud.go +++ b/docker/cloud.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "math" "sort" "strconv" "strings" @@ -513,7 +514,7 @@ func (c *Client) ContainerStats(ctx context.Context, containerID string) (Contai CPUSystemUsage: v.CPUStats.SystemUsage, OnlineCPUs: onlineCPUs, MemUsageBytes: memUsageNoCache(v.MemoryStats), - MemLimitBytes: int64(v.MemoryStats.Limit), + MemLimitBytes: saturatingInt64(v.MemoryStats.Limit), }, nil } @@ -523,12 +524,21 @@ func (c *Client) ContainerStats(ctx context.Context, containerID string) (Contai // back to the raw usage when neither is present. func memUsageNoCache(mem container.MemoryStats) int64 { if v, ok := mem.Stats["total_inactive_file"]; ok && v < mem.Usage { // cgroup v1 - return int64(mem.Usage - v) + return saturatingInt64(mem.Usage - v) } if v, ok := mem.Stats["inactive_file"]; ok && v < mem.Usage { // cgroup v2 - return int64(mem.Usage - v) + return saturatingInt64(mem.Usage - v) } - return int64(mem.Usage) + return saturatingInt64(mem.Usage) +} + +// saturatingInt64 converts a byte count from the Docker API (uint64) to the int64 the +// cloud report carries, saturating instead of wrapping to a negative value. +func saturatingInt64(u uint64) int64 { + if u > math.MaxInt64 { + return math.MaxInt64 + } + return int64(u) } // ContainerLogsTail returns the last n log lines of a container plus the total diff --git a/docs/02-installation.md b/docs/02-installation.md index 6063040..2ca4096 100644 --- a/docs/02-installation.md +++ b/docs/02-installation.md @@ -106,3 +106,25 @@ volumes: Set `TAILSCALE_AUTH_KEY` to authenticate the Tailscale container. Generate it in the Tailscale Admin Console under Settings -> Keys. The sidecar should advertise `tag:server` so it can satisfy the ACL auto-approver example below. The sidecar uses `network_mode: host` so it can reach container IPs on any Docker network. On Docker Desktop this requires enabling host networking under Settings -> Resources -> Network. Alternatively, remove `network_mode: host` and attach the sidecar to the same Docker network as the containers you expose. On rootless Docker, prefer the shared-network form; host networking is limited in that mode. + +### Platforms And Release Binaries + +The image `ghcr.io/marvinvr/docktail` is published for `linux/amd64`, `linux/arm64` and `linux/arm/v7` (Raspberry Pi 2/3 and older 32-bit ARM NAS devices). Docker picks the right one automatically; the setups above work unchanged on all three. + +Each [GitHub release](https://github.com/marvinvr/docktail/releases) also carries static Linux binaries for the same three architectures (`docktail__linux_amd64.tar.gz`, `…_linux_arm64.tar.gz`, `…_linux_armv7.tar.gz`) plus a `checksums.txt`. Use them to run DockTail directly on a Linux host instead of in a container: + +```bash +VERSION=1.9.0 # the release you want +curl -fsSLO "https://github.com/marvinvr/docktail/releases/download/${VERSION}/docktail_${VERSION}_linux_amd64.tar.gz" +curl -fsSL "https://github.com/marvinvr/docktail/releases/download/${VERSION}/checksums.txt" | sha256sum --check --ignore-missing +tar -xzf "docktail_${VERSION}_linux_amd64.tar.gz" docktail +sudo install docktail /usr/local/bin/docktail +``` + +The binary behaves like the container and reads the same [environment variables](07-reference.md#environment-variables). Running outside a container it additionally needs: + +- the `tailscale` CLI on the `PATH`, which the host's Tailscale install provides — the image bundles its own copy, the binary does not; +- read access to the Docker socket (`/var/run/docker.sock`) and access to the `tailscaled` socket (`TAILSCALE_SOCKET`, default `/var/run/tailscale/tailscaled.sock`), so run it as root or as a user in the `docker` group who is the Tailscale operator (`sudo tailscale set --operator=$USER`); +- something to keep it running, such as a systemd service with `Restart=always`, because DockTail [exits on purpose](07-reference.md#tailscale-socket-loss) when it loses the `tailscaled` socket. + +Setting up the host's tailnet tag and the Tailscale admin side is the same as for [Tailscale On Host](#tailscale-on-host). diff --git a/docs/07-reference.md b/docs/07-reference.md index 2a763c1..1e97ae2 100644 --- a/docs/07-reference.md +++ b/docs/07-reference.md @@ -165,7 +165,10 @@ the container's restart policy re-create the container and with it the mount. restart, which takes a second or two. Brief outages are ignored and never cause an exit. - The check arms only after the socket has been reachable at least once, so - starting DockTail before `tailscaled` waits rather than exits. + starting DockTail before `tailscaled` waits rather than exits. At startup + DockTail also waits up to 15 seconds for the socket before its first + reconcile, so a sidecar that is still starting does not delay your Services + by a whole `RECONCILE_INTERVAL`. - Set `EXIT_ON_SOCKET_LOSS=false` to disable it and keep the old behaviour of retrying forever. @@ -181,7 +184,7 @@ binary prints it on request: ```bash docker exec docktail /app/docktail --version # or, without a running container: -docker run --rm --entrypoint /app/docktail ghcr.io/marvinvr/docktail:latest --version +docker run --rm ghcr.io/marvinvr/docktail:latest --version ``` Include that version in bug reports. diff --git a/health/health.go b/health/health.go index fb6815b..b52910a 100644 --- a/health/health.go +++ b/health/health.go @@ -251,7 +251,7 @@ func (t *Tracker) write() error { // restart triggered by an unhealthy status (Swarm, autoheal) would drain every // service without fixing anything on the cloud side. func Check(path string, now time.Time) (bool, string) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //nolint:gosec // G304: the operator-configured status file this binary writes itself if err != nil { if os.IsNotExist(err) { return false, fmt.Sprintf("unhealthy: no status file at %s (DockTail is not running, is still starting, or cannot write it; see %s)", path, EnvFile) diff --git a/main.go b/main.go index f06cd33..566195b 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,11 @@ import ( "github.com/marvinvr/docktail/version" ) +// socketStartupWait bounds how long startup waits for a tailscaled that is not +// listening yet. DockTail carries on afterwards: the reconcile loop retries and +// the socket watchdog only arms once the socket has been reachable. +const socketStartupWait = 15 * time.Second + func main() { if len(os.Args) > 1 { os.Exit(runCommand(os.Args[1:])) @@ -134,6 +139,20 @@ func main() { DeleteUnusedServices: deleteUnusedServices, }) + // tailscaled may still be starting (a sidecar behind a plain depends_on, a + // host that is booting). Give it a bounded moment before the version check + // and the first reconcile, both of which need the daemon. A signal during + // the wait exits cleanly: nothing has been advertised yet that would need + // cleaning up. + waitCtx, stopWait := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + _ = tailscaleClient.WaitForSocket(waitCtx, socketStartupWait, 250*time.Millisecond) + interrupted := waitCtx.Err() != nil + stopWait() + if interrupted { + log.Info().Msg("Received shutdown signal while waiting for the Tailscale socket, exiting") + return + } + // Detect CLI/daemon version mismatch (common with host-mode Tailscale) tailscaleClient.DetectVersionMismatch(context.Background()) tailscaleClient.WarnIfSocketMissing() @@ -309,7 +328,7 @@ func getEnvFileValue(fileKey string) (string, bool) { return "", false } - content, err := os.ReadFile(path) + content, err := os.ReadFile(path) //nolint:gosec // G304: the operator names this file via FILE__ / _FILE on purpose if err != nil { log.Fatal(). Err(err). diff --git a/tailscale/client.go b/tailscale/client.go index 9a8120f..1d047f0 100644 --- a/tailscale/client.go +++ b/tailscale/client.go @@ -82,7 +82,7 @@ func NewClient(cfg ClientConfig) *Client { // Prefer OAuth over API key if cfg.OAuthClientID != "" && cfg.OAuthClientSecret != "" { - oauthConfig := &clientcredentials.Config{ + oauthConfig := &clientcredentials.Config{ //nolint:gosec // G101: TokenURL is a public endpoint; the credentials come from the environment ClientID: cfg.OAuthClientID, ClientSecret: cfg.OAuthClientSecret, TokenURL: "https://api.tailscale.com/api/v2/oauth/token", diff --git a/tailscale/socketwatch.go b/tailscale/socketwatch.go index 5ebe307..22273a8 100644 --- a/tailscale/socketwatch.go +++ b/tailscale/socketwatch.go @@ -53,6 +53,45 @@ func (c *Client) ProbeSocket() error { return nil } +// WaitForSocket blocks until the tailscaled socket accepts a connection, the +// timeout elapses, or ctx is cancelled, probing every interval. It returns the +// last probe error when the socket never became reachable, or ctx.Err(). +// +// DockTail usually starts alongside tailscaled (a sidecar behind a plain +// depends_on, or a host that is still booting), so the daemon may not be +// listening yet. Without this wait the startup version check would miss a +// CLI/daemon mismatch and the first reconcile would fail, leaving services +// unadvertised until the next reconcile interval. +func (c *Client) WaitForSocket(ctx context.Context, timeout, interval time.Duration) error { + err := c.ProbeSocket() + if err == nil { + return nil + } + log.Info().Err(err). + Str("socket", c.socketPath). + Dur("timeout", timeout). + Msg("Waiting for the Tailscale socket to become reachable") + + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return err + case <-ticker.C: + if err = c.ProbeSocket(); err == nil { + log.Info().Str("socket", c.socketPath).Msg("Tailscale socket is reachable") + return nil + } + } + } +} + // SocketWatchdogConfig configures the watchdog. type SocketWatchdogConfig struct { // Enabled switches the watchdog on. When false, Run returns immediately. diff --git a/tailscale/socketwatch_test.go b/tailscale/socketwatch_test.go index 6b91100..56c7d23 100644 --- a/tailscale/socketwatch_test.go +++ b/tailscale/socketwatch_test.go @@ -1,6 +1,7 @@ package tailscale import ( + "context" "errors" "net" "os" @@ -249,3 +250,56 @@ func TestWatchdogRunDisabled(t *testing.T) { }) } } + +func TestWaitForSocketAlreadyReachable(t *testing.T) { + path := socketPath(t) + listenUnix(t, path) + + c := &Client{socketPath: path} + if err := c.WaitForSocket(t.Context(), time.Second, 10*time.Millisecond); err != nil { + t.Fatalf("expected no wait for a reachable socket, got %v", err) + } +} + +// The sidecar case the startup wait exists for: tailscaled starts listening a +// moment after DockTail does. +func TestWaitForSocketBecomesReachable(t *testing.T) { + path := socketPath(t) + c := &Client{socketPath: path} + + listening := make(chan net.Listener, 1) + go func() { + time.Sleep(100 * time.Millisecond) + ln, _ := net.Listen("unix", path) // nil on error, checked below + listening <- ln + }() + + err := c.WaitForSocket(t.Context(), 5*time.Second, 10*time.Millisecond) + ln := <-listening + if ln == nil { + t.Fatal("could not listen on the test socket") + } + _ = ln.Close() + if err != nil { + t.Fatalf("expected the socket to be found once it appears, got %v", err) + } +} + +func TestWaitForSocketTimesOut(t *testing.T) { + c := &Client{socketPath: socketPath(t)} + + err := c.WaitForSocket(t.Context(), 50*time.Millisecond, 10*time.Millisecond) + if !IsSocketUnreachable(err) { + t.Fatalf("expected the last probe error after the timeout, got %v", err) + } +} + +func TestWaitForSocketCancelled(t *testing.T) { + c := &Client{socketPath: socketPath(t)} + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if err := c.WaitForSocket(ctx, 5*time.Second, 10*time.Millisecond); !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} diff --git a/tailscale/utils.go b/tailscale/utils.go index 1849d1c..425c3dd 100644 --- a/tailscale/utils.go +++ b/tailscale/utils.go @@ -18,7 +18,9 @@ import ( // tailscaled has been detected, it sets TS_DEBUG_FAKE_IPC_VERSION so the CLI // doesn't reject the connection. func (c *Client) tailscaleCmd(ctx context.Context, args ...string) *exec.Cmd { - cmd := exec.CommandContext(ctx, "tailscale", args...) + // No shell is involved: the arguments are built by this package and passed + // to the fixed tailscale binary as separate argv entries. + cmd := exec.CommandContext(ctx, "tailscale", args...) //nolint:gosec // G204: fixed binary, argv only if sv := c.getServerVersion(); sv != "" { cmd.Env = append(os.Environ(), "TS_DEBUG_FAKE_IPC_VERSION="+sv) } diff --git a/tools/docsgen/main.go b/tools/docsgen/main.go index 5b9f996..77c72b5 100644 --- a/tools/docsgen/main.go +++ b/tools/docsgen/main.go @@ -82,13 +82,13 @@ func main() { data := pageData{ Title: title, Description: "Documentation for DockTail, a tool that automatically exposes Docker containers as Tailscale Services using label-based configuration.", - Body: template.HTML(rendered), + Body: template.HTML(rendered), //nolint:gosec // G203: HTML rendered from this repo's own docs/*.md Headings: headings, Sections: buildSidebar(specs), GeneratedAt: generatedAt, } - if err := os.MkdirAll(filepath.Join(*websiteDir, "docs"), 0o755); err != nil { + if err := os.MkdirAll(filepath.Join(*websiteDir, "docs"), 0o755); err != nil { //nolint:gosec // G301: public website output, served as static files fatal(err) } @@ -121,7 +121,7 @@ func readMarkdown(sourceDir string) (string, error) { var out strings.Builder for i, path := range matches { - body, err := os.ReadFile(path) + body, err := os.ReadFile(path) //nolint:gosec // G304: path comes from globbing the docs source dir if err != nil { return "", err } @@ -234,7 +234,7 @@ func readFileSpecs(sourceDir string) ([]fileSpec, error) { specs := make([]fileSpec, 0, len(matches)) for _, path := range matches { - body, err := os.ReadFile(path) + body, err := os.ReadFile(path) //nolint:gosec // G304: path comes from globbing the docs source dir if err != nil { return nil, err } @@ -391,7 +391,7 @@ func renderSitemap(lastmod string) string { } func writeFile(path, content string) error { - return os.WriteFile(path, []byte(content), 0o644) + return os.WriteFile(path, []byte(content), 0o644) //nolint:gosec // G306: public website output, served as static files } func fatal(err error) { From 57b3520c2caa22b954415e1e89a06f327069af72 Mon Sep 17 00:00:00 2001 From: Marvin von Rappard Date: Wed, 23 Sep 2026 23:11:05 +0200 Subject: [PATCH 2/2] build: warn when the startup socket wait times out; allow re-attaching release binaries --- .github/workflows/release.yaml | 13 ++++++++++--- README.md | 2 +- docs/02-installation.md | 13 +++++++------ main.go | 8 +++++++- tailscale/socketwatch.go | 5 ++--- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index b3a8141..c1318a4 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,10 +3,17 @@ name: Release Binaries # Attaches static Linux binaries to a GitHub release once it is published. Tags # alone only produce images (docker-build.yaml); binaries follow the releases # the maintainer publishes, and the release's name and notes are left as written. +# Run it by hand to (re)attach binaries to an existing release whose tag already +# contains .goreleaser.yaml. on: release: types: - published + workflow_dispatch: + inputs: + tag: + description: Tag of an existing GitHub release + required: true jobs: binaries: @@ -14,13 +21,13 @@ jobs: permissions: contents: write env: - TAG: ${{ github.event.release.tag_name }} + TAG: ${{ github.event.release.tag_name || inputs.tag }} steps: - name: Checkout repository uses: actions/checkout@v4 with: - ref: refs/tags/${{ github.event.release.tag_name }} + ref: refs/tags/${{ env.TAG }} fetch-depth: 0 persist-credentials: false @@ -35,7 +42,7 @@ jobs: version: '~> v2' args: release --clean --skip=publish env: - GORELEASER_CURRENT_TAG: ${{ github.event.release.tag_name }} + GORELEASER_CURRENT_TAG: ${{ env.TAG }} - name: Attach archives to the release env: diff --git a/README.md b/README.md index 2586014..d8fee18 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ go build -o docktail . docker build -t docktail:latest . ``` -The image is published for `linux/amd64`, `linux/arm64` and `linux/arm/v7`, and each [release](https://github.com/marvinvr/docktail/releases) carries static Linux binaries for the same platforms; see [Platforms And Release Binaries](docs/02-installation.md#platforms-and-release-binaries). +The image is published for `linux/amd64`, `linux/arm64` and `linux/arm/v7`, and [releases](https://github.com/marvinvr/docktail/releases) newer than 1.8.3 carry static Linux binaries for the same platforms; see [Platforms And Release Binaries](docs/02-installation.md#platforms-and-release-binaries). ## Links diff --git a/docs/02-installation.md b/docs/02-installation.md index 2ca4096..b7e9dad 100644 --- a/docs/02-installation.md +++ b/docs/02-installation.md @@ -111,20 +111,21 @@ The sidecar uses `network_mode: host` so it can reach container IPs on any Docke The image `ghcr.io/marvinvr/docktail` is published for `linux/amd64`, `linux/arm64` and `linux/arm/v7` (Raspberry Pi 2/3 and older 32-bit ARM NAS devices). Docker picks the right one automatically; the setups above work unchanged on all three. -Each [GitHub release](https://github.com/marvinvr/docktail/releases) also carries static Linux binaries for the same three architectures (`docktail__linux_amd64.tar.gz`, `…_linux_arm64.tar.gz`, `…_linux_armv7.tar.gz`) plus a `checksums.txt`. Use them to run DockTail directly on a Linux host instead of in a container: +[GitHub releases](https://github.com/marvinvr/docktail/releases) newer than 1.8.3 also carry static Linux binaries for the same three architectures (`docktail__linux_amd64.tar.gz`, `…_linux_arm64.tar.gz`, `…_linux_armv7.tar.gz`) plus a `checksums.txt`. Use them to run DockTail directly on a Linux host instead of in a container: ```bash -VERSION=1.9.0 # the release you want -curl -fsSLO "https://github.com/marvinvr/docktail/releases/download/${VERSION}/docktail_${VERSION}_linux_amd64.tar.gz" -curl -fsSL "https://github.com/marvinvr/docktail/releases/download/${VERSION}/checksums.txt" | sha256sum --check --ignore-missing -tar -xzf "docktail_${VERSION}_linux_amd64.tar.gz" docktail +VERSION=1.9.0 # a release that lists binaries under its assets +ARCH=amd64 # or arm64, armv7 +curl -fsSLO "https://github.com/marvinvr/docktail/releases/download/${VERSION}/docktail_${VERSION}_linux_${ARCH}.tar.gz" +curl -fsSL "https://github.com/marvinvr/docktail/releases/download/${VERSION}/checksums.txt" | grep "_linux_${ARCH}.tar.gz" | sha256sum -c - +tar -xzf "docktail_${VERSION}_linux_${ARCH}.tar.gz" docktail sudo install docktail /usr/local/bin/docktail ``` The binary behaves like the container and reads the same [environment variables](07-reference.md#environment-variables). Running outside a container it additionally needs: - the `tailscale` CLI on the `PATH`, which the host's Tailscale install provides — the image bundles its own copy, the binary does not; -- read access to the Docker socket (`/var/run/docker.sock`) and access to the `tailscaled` socket (`TAILSCALE_SOCKET`, default `/var/run/tailscale/tailscaled.sock`), so run it as root or as a user in the `docker` group who is the Tailscale operator (`sudo tailscale set --operator=$USER`); +- access to the Docker socket (`/var/run/docker.sock`) and to the `tailscaled` socket at `/var/run/tailscale/tailscaled.sock`, the `tailscale` CLI's default, so run it as root or as a user in the `docker` group who is the Tailscale operator (`sudo tailscale set --operator=$USER`); - something to keep it running, such as a systemd service with `Restart=always`, because DockTail [exits on purpose](07-reference.md#tailscale-socket-loss) when it loses the `tailscaled` socket. Setting up the host's tailnet tag and the Tailscale admin side is the same as for [Tailscale On Host](#tailscale-on-host). diff --git a/main.go b/main.go index 566195b..909cf32 100644 --- a/main.go +++ b/main.go @@ -145,13 +145,19 @@ func main() { // the wait exits cleanly: nothing has been advertised yet that would need // cleaning up. waitCtx, stopWait := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - _ = tailscaleClient.WaitForSocket(waitCtx, socketStartupWait, 250*time.Millisecond) + waitErr := tailscaleClient.WaitForSocket(waitCtx, socketStartupWait, 250*time.Millisecond) interrupted := waitCtx.Err() != nil stopWait() if interrupted { log.Info().Msg("Received shutdown signal while waiting for the Tailscale socket, exiting") return } + if waitErr != nil { + log.Warn().Err(waitErr). + Str("socket", tailscaleSocket). + Dur("waited", socketStartupWait). + Msg("Tailscale socket is still unreachable; starting anyway and retrying on every reconcile") + } // Detect CLI/daemon version mismatch (common with host-mode Tailscale) tailscaleClient.DetectVersionMismatch(context.Background()) diff --git a/tailscale/socketwatch.go b/tailscale/socketwatch.go index 22273a8..7c534ec 100644 --- a/tailscale/socketwatch.go +++ b/tailscale/socketwatch.go @@ -59,9 +59,8 @@ func (c *Client) ProbeSocket() error { // // DockTail usually starts alongside tailscaled (a sidecar behind a plain // depends_on, or a host that is still booting), so the daemon may not be -// listening yet. Without this wait the startup version check would miss a -// CLI/daemon mismatch and the first reconcile would fail, leaving services -// unadvertised until the next reconcile interval. +// listening yet. Without this wait the first reconcile would fail, leaving +// services unadvertised until the next reconcile interval. func (c *Client) WaitForSocket(ctx context.Context, timeout, interval time.Duration) error { err := c.ProbeSocket() if err == nil {