Skip to content

feat!: v2 host agent (spectate-agent, protocol v2) - #57

Merged
bjarn merged 3 commits into
mainfrom
v2-agent
Oct 26, 2026
Merged

bjarn merged 3 commits into
mainfrom
v2-agent

Conversation

@bjarn

@bjarn bjarn commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Brings the v2 host agent (spectate-agent), which was developed in the platform monorepo under agent/, into this repository so it can be worked on here. It replaces the v1 agent (spectated), which is no longer supported.

v1

  • Releases and tags: v1.0.0 to v1.5.0 and their releases stay on GitHub, and the v1 source stays available at those tags. Stable v2 releases are marked latest, so releases/latest moves to v2 with the first v2 release.
  • install-latest.sh (the v1 installer) stops working at that moment, because it downloads releases/latest/download/spectated_<os>_<arch>.tar.gz. It stays on main for now: the platform dashboard's Add host dialog still serves it from raw.githubusercontent.com/spectate/agent/main/install-latest.sh. Delete it once the dashboard uses the v2 installer.
  • The v1 workflows (semantic-release, tag-triggered build.yml) are removed from main.
  • install.sh --import-legacy moves a v1 host to v2 and keeps its token, identity and history.

Why replace the code instead of adding a v2/ directory: keeping v1 on main gains nothing that the tags don't already provide. A root-level module with the /v2 suffix is the standard Go "major version" layout, and the repo, CI and releases stay single-purpose.

Changes

  • Module path is github.com/spectate/agent/v2. This repo already has v1.x.y tags for github.com/spectate/agent, and Go's import compatibility rule requires the /v2 suffix for v2.x.y tags. All imports and ldflags are updated. go install github.com/spectate/agent/v2/cmd/spectate-agent@v2.x.y will work.

  • Tag scheme is plain v2.x.y. The monorepo used agent/v* in the Makefile and v2.x.y in the installer. Now both use v2.x.y: Makefile git describe --match 'v2.*', release.yml runs on v2.*.*, and suffixed tags such as v2.1.0-rc.1 become pre-releases.

  • install.sh downloads from this public repo's releases. Before, it pointed at the private spectate/platform.

    • Checksum verification against checksums.txt and the hardened systemd unit (spectate-agent install) are unchanged.
    • New --version 2.0.1 flag.
    • Each release publishes an install.sh asset with DEFAULT_VERSION set to that release, so a release's installer installs the matching binary. The asset is generated by a goreleaser hook and listed in checksums.txt.
    • The copy on main (DEFAULT_VERSION="") downloads from releases/latest.
  • Protocol schema: there is now a copy at proto/spectate/agent/v2/ingest.proto, byte-identical to the canonical one in platform laravel/proto. make proto maps it to this module with --go_opt=M.... ingest.pb.go was regenerated, and only its embedded go_package changed. proto-php is removed, since PHP bindings are generated in the platform repo.

  • .goreleaser.yaml: stable releases are marked latest (pre-releases never are); release target spectate/agent, linux/darwin × amd64/arm64 static binaries, tarballs and checksums. go mod tidy -diff and go test run in the before hooks; the old go mod tidy could leave the tree dirty.

  • .github/workflows/ci.yml runs on PRs and pushes to main:

    • gofmt, go mod verify and go mod tidy -diff
    • go vet (linux and darwin), go test -race, linux and darwin builds
    • shellcheck install.sh and goreleaser check

    The main job is named vet-lint because branch protection requires that check.

  • .github/workflows/release.yml: on a v2.*.* tag it runs vet and test, then goreleaser. The workflow default is contents: read; only the release job gets contents: write, and it uses GITHUB_TOKEN rather than the old GH_TOKEN secret. Every action is pinned by commit SHA. Dependabot now also updates github-actions.

  • Removed v1 files: cmd/, internal/, main/, pkg/, the three goreleaser configs, .releaserc.json, package*.json, CHANGELOG.md and .env.example. All of them remain in git history and at the v1 tags.

  • README: covers install, release, the module path and a v1 section. LICENSE, CODE_OF_CONDUCT and CONTRIBUTING are unchanged.

Installer URL: get.spectate.net/agent.sh

Make get.spectate.net/agent.sh a redirect to https://github.com/spectate/agent/releases/latest/download/install.sh. That asset is the newest stable release's installer: it is covered by checksums.txt, carries the signing key and installs the binary of its own release. Since v2 is "latest", the redirect never needs updating. The documented command already follows redirects:

curl -fsSL https://get.spectate.net/agent.sh | sudo sh -s -- --token <install token>

get.spectate.net doesn't resolve yet.

Protocol

The agent registers with POST /agent-api/v2/register (install token → host token) and ships to POST /agent-api/v2/ingest (Authorization: Bearer <host token>, gzip + protobuf spectate.agent.v2.IngestRequest). Every ingest response carries the server-side config. Protocol docs, v1 vs v2: https://github.com/spectate/platform/blob/main/docs/host-agent.md (in the private platform repo).

Deleted hosts, clock offset, hung mounts, signed self-update

The second commit implements the agent side of the API contract, which platform main already serves.

  • Deleted host (410 + HOST_DELETED) is its own disposition (transport.HostDeleted). The agent clears the spool and the current window, logs one ERROR (This host was removed from Spectate. Remove the agent: sudo spectate-agent uninstall), writes host_deleted: true to state.json and exits with status 78. The unit has RestartPreventExitStatus=78, so systemd does not restart it. If the agent is started again with the marker set, it logs the same message and exits 78 before calling the API. register --force writes a fresh state, which clears the marker. A 410 without that code is still retried.
  • Revoked token (401): the agent keeps spooling, but retries every hour (jittered down by up to 15 min) instead of capping at 5 min. A longer Retry-After is honoured. The ERROR is logged at most once an hour.
  • Clock: every request (register and ingest) sends X-Agent-Time with the agent's unix time in ms, set in Client.do right before the request goes out. If clock_offset_ms is beyond ±30 s, the agent logs a WARN at most once an hour: host clock is off by Ns; samples are corrected by the server, but fix NTP. The agent never shifts its own timestamps. register prints the same warning.
  • Hung filesystems: each collector runs in its own goroutine, bounded by the round deadline (half the interval, at least 1 s). The round keeps what the other collectors produced, plus the stuck one's samples up to the deadline; anything it emits later is discarded. The stuck collector is reported once and isn't started again until it returns, so goroutines can't pile up. In the disk collector, each mountpoint's statfs gets its own 2 s deadline. A mountpoint whose call is still hanging is skipped until that call returns, so one dead NFS mount doesn't hide the other disks.
  • Default disk exclusions: exclude_filesystems gains network and automount filesystems, listed below. On Linux, gopsutil's non-all listing already drops all of them, because it keeps only non-nodev entries of /proc/filesystems. macOS lists every mount, including nfs/smbfs/afpfs/webdav/autofs, so the list is what keeps them out there.
  • TLS roots: golang.org/x/crypto/x509roots/fallback is blank-imported, so hosts without a CA bundle work. It adds about 260 KB to the binary.
  • spectate-agent update [--version X] [--check] [--if-newer] [--no-restart] (root):
    • It finds the highest stable v2.x.y through GET api.github.com/repos/spectate/agent/releases?per_page=30, so a pre-release or an old tag is never installed.
    • It downloads spectate-agent_<os>_<arch>, checksums.txt and checksums.txt.sig. It verifies the ed25519 signature with the key embedded at build time (version.UpdatePublicKey), then the sha256.
    • It replaces the binary atomically: a temp file in the same directory, fsync, a version self-test, rename, then a directory fsync. After that it runs systemctl restart. The timer's --if-newer uses try-restart, so a stopped agent stays stopped. On macOS it prints the launchctl commands instead.
    • It only contacts api.github.com, github.com, objects.githubusercontent.com and release-assets.githubusercontent.com, over https, including redirects. GitHub now redirects asset downloads to release-assets.*, so that host had to be added. SPECTATE_UPDATE_API_URL, used for tests, is honoured only by -dev builds.
    • Builds without a key refuse to update unless --insecure-skip-signature is passed.
  • agent_update in ingest responses: when a version newer than the running one is announced, the agent logs INFO once a day (spectate-agent X is available: sudo spectate-agent update). It also stores the version in state.json, and spectate-agent status shows it.
  • Auto-update (opt-in): install.sh --auto-update or spectate-agent install --auto-update writes spectate-agent-update.service (oneshot, root, update --if-newer) and spectate-agent-update.timer (daily, RandomizedDelaySec=6h, Persistent=true). uninstall removes both. Both units pass systemd-analyze verify.
  • Release signing: cmd/release-sign has three commands: keygen, sign <file> (key from SPECTATE_RELEASE_SIGNING_KEY) and verify <file>. sign refuses to run without the key. If SPECTATE_RELEASE_PUBLIC_KEY is set, it must belong to that private key, which catches a secret/variable mismatch.
    • goreleaser builds the tool in a before hook, signs checksums.txt with it (signs:), uploads checksums.txt.sig, and puts the public key into the ldflags and the release's install.sh.
    • release.yml fails before goreleaser if either the secret or the variable is missing, so nothing unsigned is published. make snapshot uses --skip=sign.
  • install.sh signature check (my call): it still verifies the sha256. The release-asset copy now carries the public key and also verifies checksums.txt.sig with openssl pkeyutl -verify -rawin when OpenSSL 3 is present. With LibreSSL or OpenSSL 1.1 it prints a warning and relies on the checksum. The repo copy has no key, so it checks checksums only. spectate-agent update always verifies the signature.

Default config change for the server (laravel/config/host_agent.php, already on platform main)

defaults.disk.exclude_filesystems changed from ['squashfs', 'vfat'] to:

['squashfs', 'vfat', 'nfs', 'nfs4', 'cifs', 'smbfs', 'smb3', 'afpfs', 'webdav', 'autofs', 'fuse.sshfs', 'ceph', 'glusterfs', 'fuse.glusterfs', '9p']

No other defaults changed.

Verification (local)

gofmt -l .                      # clean
go mod tidy -diff               # clean
go vet ./... && GOOS=darwin go vet ./... && GOOS=linux go vet ./...   # ok
go test -race ./...             # ok on darwin and in a linux container (golang:1.26)
shellcheck install.sh           # clean
actionlint                      # clean
goreleaser check                # valid
goreleaser release --snapshot --clean   # with a throwaway key: checksums.txt.sig produced and verified, key embedded in binaries and install.sh
                                        # without the key: fails at the sign step; make snapshot (--skip=sign) succeeds
systemd-analyze verify          # agent unit, update service and timer: ok (debian:trixie)

The self-update end-to-end test (cmd/spectate-agent/update_e2e_test.go, skipped with -short) does the following:

  • builds a 2.0.0-dev binary and a 2.1.0 release with a throwaway key, and serves fake release JSON and assets, with redirects, from httptest
  • checks that --check doesn't install anything
  • checks that a foreign signature, a tampered binary and a tampered checksums.txt are rejected and leave the binary untouched
  • checks that a good release replaces the binary atomically
  • checks that --if-newer is silent when the agent is current, release builds ignore the API override, and keyless builds refuse to update

The install.sh OpenSSL path was tested under sh and dash with good, foreign and tampered signatures, and with LibreSSL (warning fallback).

After merging

  1. Release signing key (required before the first v2 release, or the release job fails):
    • Run go run ./cmd/release-sign keygen on a trusted machine.
    • gh secret set SPECTATE_RELEASE_SIGNING_KEY -R spectate/agent (paste the private key) and gh variable set SPECTATE_RELEASE_PUBLIC_KEY -R spectate/agent --body <public key>.
    • Keep an offline copy of the private key.
    • Optionally commit the public key into UPDATE_PUBLIC_KEY="" in install.sh on main, so the repo copy verifies signatures too. The goreleaser hook overwrites that line in the release asset either way.
  2. Point get.spectate.net/agent.sh at https://github.com/spectate/agent/releases/latest/download/install.sh (DNS first: the name doesn't resolve yet).
  3. Tag the first release: git tag -s v2.0.0 -m v2.0.0 && git push origin v2.0.0. It becomes "latest", which ends v1 installs.
  4. In platform, right after the release: switch the dashboard's Add host dialog (and the integrations page, which still offers v1) to the v2 command, then delete install-latest.sh here.
  5. In platform: the Laravel golden test reads ../agent/internal/encode/testdata/*. Move those fixtures into laravel/tests before agent/ is removed from the monorepo. Keep laravel/proto and proto/ here in sync.

bjarn added 3 commits October 23, 2026 16:27
Replace the frozen v1 agent (spectated) on main with the v2 agent that was
developed in spectate/platform (agent/).

- Module path github.com/spectate/agent/v2 (v1 was released as v1.x.y).
- Tags v2.x.y; releases via goreleaser on tag push, never marked latest so
  v1's install-latest.sh keeps resolving releases/latest to v1.5.0.
- install.sh downloads from this repository's releases and verifies
  checksums.txt; each release ships install.sh pinned to its version.
- CI: gofmt, go mod verify/tidy, vet (linux + darwin), test -race, build,
  shellcheck and goreleaser check; actions pinned by commit SHA.
- v1 source stays available at the v1.x.y tags; install-latest.sh is kept
  unchanged for existing v1 installs.
…elf-update

- 410 HOST_DELETED is its own disposition: the agent clears the spool,
  logs one error, persists host_deleted in state.json and exits 78, which
  the unit does not restart on (RestartPreventExitStatus=78). Later starts
  exit the same way without calling the API; register --force clears it.
- 401 keeps spooling but retries hourly (honouring a longer Retry-After)
  and logs at ERROR at most once an hour.
- Every request carries X-Agent-Time (unix ms at send time). A
  clock_offset_ms beyond 30s is warned about hourly; timestamps are not
  shifted (the server corrects them).
- Collectors run concurrently under the round deadline; a stuck collector
  is reported once and not restarted until it returns. Each mountpoint's
  statfs has its own deadline and a hanging mountpoint is skipped until its
  call returns. Network/automount filesystems are excluded by default
  (needed on macOS; gopsutil already drops them on Linux).
- Bundled CA roots (x509roots/fallback) for hosts without a CA bundle.
- spectate-agent update [--version] [--check] [--if-newer]: newest stable
  v2 release via the GitHub API, ed25519-signed checksums.txt verified with
  the key embedded at build time, sha256 check, atomic replace with a
  self-test, systemctl restart. Downloads only from GitHub hosts.
- agent_update from ingest is logged daily and shown in status; optional
  daily auto-update timer (install --auto-update, install.sh --auto-update).
- cmd/release-sign (keygen/sign/verify); goreleaser signs checksums.txt
  and uploads checksums.txt.sig; the release fails without the key.
  install.sh verifies the signature with OpenSSL 3 when available.
v1 (spectated) is no longer supported, so releases are no longer kept off
"latest": install.sh downloads from releases/latest instead of looking up
the newest v2 tag through the GitHub API, and
releases/latest/download/install.sh is a stable installer URL.

install-latest.sh (v1) stays until the dashboard's add-host command uses the
v2 installer; it stops working once v2.0.0 is released.
@bjarn
bjarn merged commit 78bb911 into main Oct 26, 2026
2 checks passed
@bjarn
bjarn deleted the v2-agent branch October 26, 2026 09:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant