diff --git a/.gitea/workflows/agent-release.yml b/.gitea/workflows/agent-release.yml new file mode 100644 index 0000000..69cda1e --- /dev/null +++ b/.gitea/workflows/agent-release.yml @@ -0,0 +1,142 @@ +name: Agent Release + +# The tag stays "agent/v*" rather than becoming a bare "v*", even though this +# repository is only the agent now. The tag is not a private detail: it is the +# release the fleet downloads by, it is what UpdateAgentCmd carries, and the +# control plane finds the newest one by grepping tag names for that exact +# prefix. Renaming it would be a second breaking change stacked on the +# repository move. +on: + push: + tags: + - "agent/v*" + +jobs: + build: + runs-on: ubuntu-docker + container: node:26 + env: + GOPRIVATE: gitea.hostxtra.co.uk/* + steps: + - name: Checkout + uses: actions/checkout@v4 + + # vantage-shared is a private module, so the Go builds below cannot + # resolve it without a credential. + - name: Write the module fetch credential + run: | + umask 077 + printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \ + "${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \ + > "$HOME/.netrc" + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26" + cache: true + cache-dependency-path: go.sum + + - name: Extract version + id: version + run: echo "VERSION=${GITHUB_REF_NAME#agent/}" >> $GITHUB_OUTPUT + + - name: Build + env: + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + mkdir -p dist + GOOS=linux GOARCH=amd64 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o dist/vantage-agent-linux-amd64 ./cmd + GOOS=linux GOARCH=arm64 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o dist/vantage-agent-linux-arm64 ./cmd + GOOS=windows GOARCH=amd64 go build \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o dist/vantage-agent-windows-amd64.exe ./cmd + + - name: Checksums + working-directory: dist + run: sha256sum vantage-agent-linux-amd64 vantage-agent-linux-arm64 vantage-agent-windows-amd64.exe > checksums.txt + + - name: Create release + uses: https://gitea.com/actions/gitea-release-action@v1 + with: + token: ${{ secrets.RELEASE_TOKEN }} + files: | + dist/vantage-agent-linux-amd64 + dist/vantage-agent-linux-arm64 + dist/vantage-agent-windows-amd64.exe + dist/checksums.txt + + msi: + needs: build + runs-on: windows-2022 + env: + GOPRIVATE: gitea.hostxtra.co.uk/* + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Same private-module credential as the build job, in the file + # Windows Go looks for: _netrc in the profile directory, not .netrc. + - name: Write the module fetch credential + shell: pwsh + run: | + "machine gitea.hostxtra.co.uk`nlogin ${{ secrets.REGISTRY_USER }}`npassword ${{ secrets.RELEASE_TOKEN }}" | + Out-File -Encoding ascii "$env:USERPROFILE\_netrc" + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26" + cache: true + cache-dependency-path: go.sum + + - name: Extract version + id: version + shell: pwsh + run: | + $v = "${{ github.ref_name }}" -replace '^agent/v', '' + "VERSION=$v" | Out-File -Append $env:GITHUB_OUTPUT + # MSI ProductVersion must be numeric x.x.x.x + "MSIVERSION=$v.0" | Out-File -Append $env:GITHUB_OUTPUT + + - name: Build agent exe + shell: pwsh + env: + VERSION: ${{ steps.version.outputs.VERSION }} + run: | + $env:GOOS = "windows"; $env:GOARCH = "amd64" + go build -ldflags="-s -w -X main.Version=$env:VERSION" -o installer/vantage-agent-windows-amd64.exe ./cmd + + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 5.* + + - name: Build MSI + working-directory: installer + shell: pwsh + run: | + $env:PATH = "$env:PATH;$env:USERPROFILE\.dotnet\tools" + wix build vantage-agent.wxs -d Version=${{ steps.version.outputs.MSIVERSION }} -o vantage-agent.msi + (Get-FileHash vantage-agent.msi -Algorithm SHA256).Hash.ToLower() + " vantage-agent.msi" | Out-File -Encoding ascii checksums-msi.txt + + - name: Attach MSI to release + working-directory: installer + shell: pwsh + env: + TOKEN: ${{ secrets.RELEASE_TOKEN }} + run: | + $api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}" + $tag = [uri]::EscapeDataString("${{ github.ref_name }}") + $headers = @{ Authorization = "token $env:TOKEN" } + # gitea-release-action can't find a slashed tag, so append via the API directly + $rel = Invoke-RestMethod -Headers $headers -Uri "$api/releases/tags/$tag" + foreach ($f in "vantage-agent.msi", "checksums-msi.txt") { + $name = [uri]::EscapeDataString($f) + Invoke-RestMethod -Headers $headers -Method Post -InFile $f ` + -ContentType "application/octet-stream" ` + -Uri "$api/releases/$($rel.id)/assets?name=$name" + } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10c0c43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +dist +installer/vantage-agent-windows-amd64.exe +installer/vantage-agent.msi +installer/checksums-msi.txt +.env diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9c2b042 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,137 @@ +# Vantage agent (`vantage-agent`) + +The lightweight Go agent installed on every managed server, and the Windows +installer that packages it. Extracted from the `vantage` monorepo with its +history; the agent is the repository root, so the module is +`gitea.hostxtra.co.uk/vantage/vantage-agent`. + +``` +vantage-agent/ +├── cmd/main.go # flags: -generate-key +├── internal/ +│ ├── checker/ # monitor check execution +│ ├── config/ # config.yaml load/save +│ ├── exec/ # workflow step execution +│ ├── grpc/ # client + the codec registration +│ ├── inventory/ # CPU/mem/disk collection (linux/other/windows) +│ ├── keys/ # authorized_keys read/diff/write +│ ├── monitors/ # agent-run monitor loop +│ ├── proxy/ # console relay, always from 127.0.0.1 +│ ├── sync/ # poll loop + command stream + self-update +│ ├── updates/ # OS package update check/apply +│ ├── winexec/ # PowerShell invocation on Windows +│ └── workloads/ # containers and units/services +├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs +└── .gitea/workflows/agent-release.yml +``` + +## Relationship to the other repositories + +| Repository | Relationship | +| ---------------- | ------------------------------------------------------------------------------------------------------ | +| `vantage-shared` | a private Go module. `grpc/pb` and `grpc/codec` are the wire contract; `proto/` there documents them | +| `vantage` | the control plane. **No import in either direction** — the coupling is the gRPC wire, and it is entirely mediated by `vantage-shared` | + +**`vantage-shared` is private**, so every Go build needs +`GOPRIVATE=gitea.hostxtra.co.uk/*` plus a credential. CI writes a netrc from +`REGISTRY_USER` + `RELEASE_TOKEN` (**that token needs read access to the +`vantage` org**) — twice, because the `msi` job is Windows and Go looks for +`_netrc` in the profile directory there, not `.netrc`. Locally, either a netrc +or `git config --global url."git@gitea.hostxtra.co.uk:".insteadOf https://gitea.hostxtra.co.uk/`. + +### A wire change is three steps, in order + +`shared/grpc/pb` is hand-written and shared by both sides, so a new message is a +compile error rather than a silent disagreement — but only once each side moves: + +1. release `vantage-shared` (and change `proto/vantage/v1/vantage.proto` in the + same commit as the Go types) +2. bump the pin in `vantage`'s `server/go.mod` — live at the next push to main +3. bump the pin here — live only at the next `agent/v*` tag + +The control plane runs ahead of the fleet in between. That was true before the +split too; it is now explicit in two `go.mod` files rather than implicit in a +shared directory. + +## Releases + +Tags are `agent/v*`, **not** bare `v*`, even though this repository is only the +agent. The tag is not a private detail: it is what the fleet downloads by, what +`UpdateAgentCmd` carries, and what the control plane greps release tag names for +when answering `GET /api/agent/latest-version`. Renaming it would be a second +breaking change stacked on the repository move. + +```bash +git tag agent/v1.2.0 && git push origin agent/v1.2.0 +``` + +Builds `linux/amd64`, `linux/arm64` and `windows/amd64`, writes `checksums.txt`, +creates the Gitea release. A second `msi` job on `windows-2022` builds the exe +again, packages it with WiX and appends the MSI to the same release **through +the API** — `gitea-release-action` cannot find a tag with a slash in it. + +### The self-update path, and what the move broke + +`internal/sync` downloads its own replacement from +`/vantage/vantage-agent/releases/download//…`, verifying the +SHA-256 from `checksums.txt` before swapping itself. That path is **compiled +into the binary**. + +**Every agent built before this move has the old path — `mrhid6/vantage` — +baked in, and releases are no longer published there.** For those agents the +push-button update in the UI will fail: the download 404s. They are not +stranded, because `/update` and `/update.ps1` are generated by the control plane +at request time and point wherever the current server says, so re-running the +update one-liner on a host moves it onto a build that knows the new address. +After that, self-update works again permanently. + +This was a deliberate choice — the alternative was publishing releases to a +repository that no longer holds the source — but it means **the fleet needs one +pass of the update one-liner**, and the control plane must be redeployed with +the new release paths *first*, or the one-liner points at the old repository +too. + +## What the agent will not do + +- **It never reboots a host.** `ApplyUpdatesCmd` installs and stops there; + `inventory.reboot_required` reports that one is owed. +- **It decides what it will not touch.** The protected workload set is computed + and enforced agent-side — `vantage-agent.service`, `VantageAgent` on Windows, + and its own container ID from `/proc/self/cgroup`. The control plane may name + a target; the agent decides what it will do to itself. A server-side denylist + alone would be bypassed by the next dispatch path someone adds, and the + failure is unrecoverable from the UI. +- **The console relay dials `127.0.0.1` only.** The host is hardcoded here, so + the control plane can name a port and nothing else. +- **No `authorized_keys` management on Windows**, and no package inventory: a + Windows agent never calls `ReportPackages`, so no `server_packages` document + exists for it at all — a different, earlier state than the `unsupported` a + Linux distribution reaches when its family has no security feed. + +## Platform split + +Windows support is build tags, not runtime branches — `systemd_linux.go` / +`services_windows.go` and the matching `control_` and `logs_` pairs. Windows +collection runs PowerShell through `internal/winexec`, and **every script that +reports data emits JSON that a build-tag-free parser reads**, so those parsers +are tested on Linux. This module has no Windows CI: the control verbs and +`serviceDisplayName` emit no JSON, have no parser, and are exercised only by +running the agent on Windows. + +Windows updates go through the Windows Update COM API +(`Microsoft.Update.Session`) rather than the PSWindowsUpdate module, which would +need a PowerShell Gallery install on every host and fails on an air-gapped +fleet. `CurrentVersion` is empty on Windows and `NewVersion` carries the KB +article ID: a Windows update is not a version bump of a named package. + +## Two constants that mirror the control plane + +Neither can be shared — this is a separate module and the control plane's are +under `internal/` — so both must change in step, by hand: + +- the workload log cap, **500 lines and 256KB whichever binds first**, mirrored + in the control plane's `services.MaxWorkloadLogLines` +- `streamHealthyAfter` and the 70s command-stream watchdog, which pair with the + control plane's 20s `PingCmd`. The watchdog arms only **after** a first ping + has been seen, so an older server that sends none is treated as working rather + than put into a reconnect loop. diff --git a/cmd/main.go b/cmd/main.go index f26bfa1..8429e7a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,8 +7,8 @@ import ( "os/signal" "syscall" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config" - agentsync "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/sync" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config" + agentsync "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/sync" ) var Version = "dev" diff --git a/go.mod b/go.mod index b1da6f8..760cf55 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module gitea.hostxtra.co.uk/mrhid6/vantage/agent +module gitea.hostxtra.co.uk/vantage/vantage-agent go 1.26 diff --git a/internal/monitors/monitors.go b/internal/monitors/monitors.go index 5501e1d..52a5cf7 100644 --- a/internal/monitors/monitors.go +++ b/internal/monitors/monitors.go @@ -6,9 +6,9 @@ import ( "sync" "time" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/checker" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config" - grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/checker" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config" + grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc" "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" ) diff --git a/internal/sync/packages.go b/internal/sync/packages.go index 4d3af8c..d50945d 100644 --- a/internal/sync/packages.go +++ b/internal/sync/packages.go @@ -8,9 +8,9 @@ import ( "sync/atomic" "time" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config" - grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/packages" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config" + grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/packages" "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" ) diff --git a/internal/sync/sync.go b/internal/sync/sync.go index cac3c9f..0890f7f 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -17,14 +17,14 @@ import ( "sync" "time" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config" - agentexec "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/exec" - grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/inventory" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/keys" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/monitors" - agentproxy "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/proxy" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/updates" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config" + agentexec "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/exec" + grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/inventory" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/keys" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/monitors" + agentproxy "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/proxy" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/updates" "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" ) @@ -554,8 +554,8 @@ func handleUpdateAgent(cmd *pb.ServerCommand) { u := cmd.UpdateAgent arch := runtime.GOARCH tag := "agent%2Fv" + u.Version - binaryURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch) - checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag) + binaryURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch) + checksumURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag) log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId) @@ -592,8 +592,8 @@ func handleUpdateAgent(cmd *pb.ServerCommand) { func handleUpdateAgentWindows(cmd *pb.ServerCommand) { u := cmd.UpdateAgent tag := "agent%2Fv" + u.Version - msiURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent.msi", u.GiteaBaseURL, tag) - checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums-msi.txt", u.GiteaBaseURL, tag) + msiURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/vantage-agent.msi", u.GiteaBaseURL, tag) + checksumURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/checksums-msi.txt", u.GiteaBaseURL, tag) log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId) diff --git a/internal/sync/workloads.go b/internal/sync/workloads.go index 5479a6d..57b2fbc 100644 --- a/internal/sync/workloads.go +++ b/internal/sync/workloads.go @@ -5,9 +5,9 @@ import ( "log" "time" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config" - grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/workloads" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config" + grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/workloads" "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" ) diff --git a/internal/updates/updates_windows.go b/internal/updates/updates_windows.go index 1e73729..7dfc46f 100644 --- a/internal/updates/updates_windows.go +++ b/internal/updates/updates_windows.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/winexec" ) const ( diff --git a/internal/workloads/control_windows.go b/internal/workloads/control_windows.go index 6fabf88..2f3dcaf 100644 --- a/internal/workloads/control_windows.go +++ b/internal/workloads/control_windows.go @@ -6,7 +6,7 @@ import ( "os/exec" "strings" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/winexec" ) // AgentUnit is the service this agent runs as — the NSSM service name written diff --git a/internal/workloads/logs_windows.go b/internal/workloads/logs_windows.go index 817d850..f55fe0e 100644 --- a/internal/workloads/logs_windows.go +++ b/internal/workloads/logs_windows.go @@ -6,7 +6,7 @@ import ( "os/exec" "strconv" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/winexec" ) func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error) { diff --git a/internal/workloads/services_windows.go b/internal/workloads/services_windows.go index b6790cc..02780b3 100644 --- a/internal/workloads/services_windows.go +++ b/internal/workloads/services_windows.go @@ -5,7 +5,7 @@ import ( "os" "time" - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec" + "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/winexec" ) const servicesTimeout = 60 * time.Second