From 44d9036440a20892eedc0c03d549080e267b6d2a Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 8 Sep 2026 09:00:22 +0000 Subject: [PATCH] refactor: move the agent and its installer to vantage-agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent/ becomes the root of gitea.hostxtra.co.uk/vantage/vantage-agent, with installer/ alongside it, and agent-release.yml goes with them. Releases now come from that repository, so the six places this server generates or reads a release URL are repointed: both install scripts, both update scripts, and the latest-version lookup in dispatch.go. The agent/v* tag prefix is unchanged — those scripts grep for it. Agents built before this move have the old mrhid6/vantage path compiled into their self-update and will 404 on the push-button update. The remedy is the /update one-liner, which this server generates and which therefore has to ship first. --- .gitea/workflows/agent-release.yml | 138 ---- CLAUDE.md | 95 ++- agent/cmd/main.go | 39 - agent/go.mod | 17 - agent/go.sum | 20 - agent/internal/checker/checker.go | 227 ------ agent/internal/config/config.go | 59 -- agent/internal/exec/exec.go | 147 ---- agent/internal/grpc/client.go | 189 ----- agent/internal/inventory/collect_linux.go | 158 ---- agent/internal/inventory/collect_other.go | 12 - agent/internal/inventory/collect_windows.go | 183 ----- agent/internal/inventory/inventory.go | 9 - agent/internal/keys/keys.go | 228 ------ agent/internal/monitors/monitors.go | 158 ---- agent/internal/packages/osrelease.go | 63 -- agent/internal/packages/packages.go | 73 -- agent/internal/packages/parse.go | 159 ---- agent/internal/proxy/proxy.go | 114 --- agent/internal/sync/packages.go | 125 --- agent/internal/sync/sync.go | 808 ------------------- agent/internal/sync/workloads.go | 152 ---- agent/internal/updates/updates.go | 24 - agent/internal/updates/updates_linux.go | 252 ------ agent/internal/updates/updates_other.go | 9 - agent/internal/updates/updates_windows.go | 117 --- agent/internal/updates/winparse.go | 48 -- agent/internal/updates/winparse_test.go | 69 -- agent/internal/winexec/encode.go | 24 - agent/internal/winexec/encode_test.go | 20 - agent/internal/winexec/run_windows.go | 35 - agent/internal/workloads/control.go | 64 -- agent/internal/workloads/control_linux.go | 56 -- agent/internal/workloads/control_windows.go | 74 -- agent/internal/workloads/docker.go | 141 ---- agent/internal/workloads/logs.go | 66 -- agent/internal/workloads/logs_linux.go | 30 - agent/internal/workloads/logs_windows.go | 89 -- agent/internal/workloads/services_windows.go | 50 -- agent/internal/workloads/systemd_linux.go | 94 --- agent/internal/workloads/units_other.go | 22 - agent/internal/workloads/winparse.go | 224 ----- agent/internal/workloads/winparse_test.go | 207 ----- agent/internal/workloads/workloads.go | 60 -- go.work | 1 - installer/nssm.exe | Bin 331264 -> 0 bytes installer/setup.ps1 | 138 ---- installer/vantage-agent.wxs | 82 -- server/internal/api/handlers.go | 12 +- server/internal/api/install_ps1.go | 8 +- server/internal/services/dispatch.go | 2 +- 51 files changed, 62 insertions(+), 5129 deletions(-) delete mode 100644 .gitea/workflows/agent-release.yml delete mode 100644 agent/cmd/main.go delete mode 100644 agent/go.mod delete mode 100644 agent/go.sum delete mode 100644 agent/internal/checker/checker.go delete mode 100644 agent/internal/config/config.go delete mode 100644 agent/internal/exec/exec.go delete mode 100644 agent/internal/grpc/client.go delete mode 100644 agent/internal/inventory/collect_linux.go delete mode 100644 agent/internal/inventory/collect_other.go delete mode 100644 agent/internal/inventory/collect_windows.go delete mode 100644 agent/internal/inventory/inventory.go delete mode 100644 agent/internal/keys/keys.go delete mode 100644 agent/internal/monitors/monitors.go delete mode 100644 agent/internal/packages/osrelease.go delete mode 100644 agent/internal/packages/packages.go delete mode 100644 agent/internal/packages/parse.go delete mode 100644 agent/internal/proxy/proxy.go delete mode 100644 agent/internal/sync/packages.go delete mode 100644 agent/internal/sync/sync.go delete mode 100644 agent/internal/sync/workloads.go delete mode 100644 agent/internal/updates/updates.go delete mode 100644 agent/internal/updates/updates_linux.go delete mode 100644 agent/internal/updates/updates_other.go delete mode 100644 agent/internal/updates/updates_windows.go delete mode 100644 agent/internal/updates/winparse.go delete mode 100644 agent/internal/updates/winparse_test.go delete mode 100644 agent/internal/winexec/encode.go delete mode 100644 agent/internal/winexec/encode_test.go delete mode 100644 agent/internal/winexec/run_windows.go delete mode 100644 agent/internal/workloads/control.go delete mode 100644 agent/internal/workloads/control_linux.go delete mode 100644 agent/internal/workloads/control_windows.go delete mode 100644 agent/internal/workloads/docker.go delete mode 100644 agent/internal/workloads/logs.go delete mode 100644 agent/internal/workloads/logs_linux.go delete mode 100644 agent/internal/workloads/logs_windows.go delete mode 100644 agent/internal/workloads/services_windows.go delete mode 100644 agent/internal/workloads/systemd_linux.go delete mode 100644 agent/internal/workloads/units_other.go delete mode 100644 agent/internal/workloads/winparse.go delete mode 100644 agent/internal/workloads/winparse_test.go delete mode 100644 agent/internal/workloads/workloads.go delete mode 100644 installer/nssm.exe delete mode 100644 installer/setup.ps1 delete mode 100644 installer/vantage-agent.wxs diff --git a/.gitea/workflows/agent-release.yml b/.gitea/workflows/agent-release.yml deleted file mode 100644 index f3ce83d..0000000 --- a/.gitea/workflows/agent-release.yml +++ /dev/null @@ -1,138 +0,0 @@ -name: Agent Release - -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: agent/go.sum - - - name: Extract version - id: version - run: echo "VERSION=${GITHUB_REF_NAME#agent/}" >> $GITHUB_OUTPUT - - - name: Build - working-directory: agent - 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: agent/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: | - agent/dist/vantage-agent-linux-amd64 - agent/dist/vantage-agent-linux-arm64 - agent/dist/vantage-agent-windows-amd64.exe - agent/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: agent/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 - working-directory: agent - 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/CLAUDE.md b/CLAUDE.md index 26ad1e1..448a743 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,18 +39,6 @@ Multi-tenancy: every domain document carries `org_id`, and every service query i ``` vantage/ -├── agent/ -│ ├── cmd/main.go # flags: -generate-key -│ └── internal/ -│ ├── checker/ # monitor check execution -│ ├── config/ # config.yaml load/save -│ ├── exec/ # workflow step execution -│ ├── grpc/ # client + generated pb -│ ├── inventory/ # CPU/mem/disk collection (linux/other) -│ ├── keys/ # authorized_keys read/diff/write -│ ├── monitors/ # agent-run monitor loop -│ ├── sync/ # poll loop + command stream -│ └── updates/ # OS package update check/apply ├── server/ │ ├── cmd/main.go │ └── internal/ @@ -69,11 +57,11 @@ vantage/ │ ├── components/ # ui/, workflows/, monitors/, Sidebar │ └── lib/ # api client, guac console, query client ├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs -├── deploy/ # docker-compose.yml, agent.service -└── .gitea/workflows/ # agent-release.yml, server-deploy.yml +├── deploy/ # docker-compose.yml, Helm chart +└── .gitea/workflows/ # server-deploy.yml, chart-release.yml, vantagectl-release.yml ``` -**Three repositories carry parts of Vantage that this one does not.** +**Four repositories carry parts of Vantage that this one does not.** | Repository | What it holds | | ---------------- | ------------------------------------------------------------------------------------------------- | @@ -81,6 +69,7 @@ vantage/ | `vantage-admin` | Vantage HQ: the licensing authority (`server/`, was `admin/`) and its console (`web/`, was `adminsite/`) | | `vantage-site` | the marketing site (`web/`, was `site/`) and its contact-form service (`server/`, was `sitesvc/`) | | `vantage-docs` | the user documentation, at the repository root (was `docsite/`) | +| `vantage-agent` | the agent, at the repository root (was `agent/`), and the Windows `installer/` | **None of the three is a build dependency of anything here**, and nothing here is a dependency of them. `vantage-site` and `vantage-docs` are wholly @@ -101,9 +90,9 @@ email system: transport plus templates), `license/` (payload, sign, verify, trusted keys, plans), `models/` (Instance, User, Settings), `provision/`, `backup/`, `cryptobox/`, `indexes/`, `grpc/pb` + `grpc/codec`, and `cmd/lkctl/`, and `proto/vantage/v1/vantage.proto`, which documents `grpc/pb` -and moved there to sit beside it. Three modules here depend on it — `server`, `agent` and +and moved there to sit beside it. Two modules here depend on it — `server` and `vantagectl` — each pinning a version in its own `go.mod`, as do -`vantage-admin` and `vantage-site`. It was a +`vantage-admin`, `vantage-site` and `vantage-agent`. It was a directory in this repository until it was extracted with its history; the `replace ../shared` directives and the `./shared` entry in `go.work` are gone with it. @@ -403,7 +392,7 @@ one wire shape, worded per platform in the UI, which is the only layer that knows the host's OS. The platform split lives entirely in the agent, as build tags (`systemd_linux.go` / `services_windows.go` and the matching `control_` and `logs_` pairs); the control plane is OS-blind and needed no changes. -Windows collection runs PowerShell through `agent/internal/winexec`. Every +Windows collection runs PowerShell through the agent's `internal/winexec`. Every script that reports data emits JSON that a build-tag-free parser reads, so those parsers are tested on Linux — the agent module has no Windows CI. The control verbs and `serviceDisplayName` emit no JSON and have no parser; they @@ -459,9 +448,9 @@ than an empty list. Logs are capped at **500 lines and 256KB, whichever binds first** — a line count alone does not bound size, and 500 lines of 4KB JSON is 2MB across the bus. The -cap is mirrored in `services.MaxWorkloadLogLines` because `agent/` is a separate -module with an `internal/` tree and the constant cannot be shared; change one, -change the other. There is **no follow mode**: the browser console already gives +cap is mirrored in `services.MaxWorkloadLogLines` because the agent is a +separate module — a separate repository now — with an `internal/` tree, and the +constant cannot be shared; change one, change the other. There is **no follow mode**: the browser console already gives a real terminal where `docker logs -f` works properly. Log reads and control actions are **owner|admin and audited**, unlike the read-only snapshot — a container's stdout is arbitrary and cannot be masked the way a workflow's can. @@ -478,7 +467,7 @@ because that co-location is the only thing making "add the message to both in the same commit" possible. It is **one** `pb` package serving both sides. There used to be two -(`agent/internal/grpc/pb` and `server/internal/grpc/pb`) and they had already +(one in the agent, one in the server) and they had already drifted: the agent's `UnimplementedVantageServer` was three methods stale and carried no `ReportWorkloads` at all. The agent links the server half as dead code, which the linker drops. @@ -597,7 +586,25 @@ inserted in front. The same setting also decides the address recorded in ### Agent self-update -`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself. +`UpdateAgentCmd` carries a target version and Gitea base URL; the agent +downloads and replaces itself, from +`/vantage/vantage-agent/releases/download//…`. + +**That repository path is compiled into the agent, not sent to it**, and it +changed when the agent moved out of this repository. Agents built before that +move look for `mrhid6/vantage`, where releases are no longer published, so the +push-button update in the UI fails for them with a 404. They are not stranded: +`/install`, `/install.ps1`, `/update` and `/update.ps1` are generated **here**, +at request time, so re-running the update one-liner on a host moves it onto a +build that knows the new address, after which self-update works again. + +The ordering matters. This server must be deployed with the new paths *before* +the one-liner is any use, because it is this server that hands out the URL. + +The six generators — two install scripts, two update scripts, +`GET /api/agent/latest-version` in `services/dispatch.go`, and the tag lookup +inside each — all name that repository. They must agree with wherever +`agent-release.yml` actually publishes, and nothing checks that they do. ### Backup and restore @@ -1088,9 +1095,13 @@ Index builders (`EnsureAuthIndexes`, `EnsureSettingsIndexes`) are fatal on failu ## Agent Lifecycle +The agent is `vantage-agent` now; its internals are documented there. What the +control plane depends on: + ### Config file -Linux `/etc/vantage/config.yaml`, Windows `%ProgramData%\vantage\config.yaml`. Directory `0700`, file `0600`. +Linux `/etc/vantage/config.yaml`, Windows `%ProgramData%\vantage\config.yaml`. +Directory `0700`, file `0600`. ```yaml server_url: "vantage.yourdomain.com:9090" @@ -1121,8 +1132,10 @@ tls: true ### Install -Linux: systemd unit at `/etc/systemd/system/vantage-agent.service`, `Restart=always`, runs as root. -Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent as a service via NSSM. +Linux: systemd unit at `/etc/systemd/system/vantage-agent.service`, +`Restart=always`, runs as root — written by the install script this server +generates, not shipped as a file. Windows: MSI built by `vantage-agent`'s CI +(WiX), or its `installer/setup.ps1` registering the agent as a service via NSSM. --- @@ -1314,16 +1327,6 @@ lives — one copy instead of the three that existed while they were apart. ## CI/CD — Gitea Actions -### `agent-release.yml` — triggered by `agent/v*` tags - -Builds `linux/amd64`, `linux/arm64`, `windows/amd64`, writes `checksums.txt`, creates a Gitea release. A second `msi` job on `windows-2022` packages the WiX installer. - -```bash -GOOS=linux GOARCH=amd64 go build \ - -ldflags="-s -w -X main.Version=${VERSION}" \ - -o dist/vantage-agent-linux-amd64 ./cmd -``` - ### `server-deploy.yml` — triggered on every push to `main` Builds and pushes **two** images to the Gitea container registry: `server` and `web`. Everything else that used to be built here now belongs to the repository that owns it — `vantage-site`, `vantage-docs` and `vantage-admin` each publish their own. **`vantagectl` is also not among them** — it is a released tool rather than a running service, and its image is version-tagged by `vantagectl-release.yml`. @@ -1355,10 +1358,11 @@ you a pin is stale. Every Go build in these workflows writes a netrc from `REGISTRY_USER` + `RELEASE_TOKEN` before it runs, and sets `GOPRIVATE=gitea.hostxtra.co.uk/*`. -There are **five** such places, and each needs its own because jobs do not share -a filesystem: `server-deploy.yml`'s single job, both jobs of -`agent-release.yml` (the `msi` job is Windows, where Go reads `%USERPROFILE%\_netrc`, -not `.netrc`) and both jobs of `vantagectl-release.yml`. The docker builds pass +There are **three** such places here, and each needs its own because jobs do not +share a filesystem: `server-deploy.yml`'s single job and both jobs of +`vantagectl-release.yml`. The other repositories each carry their own — +`vantage-agent`'s `msi` job is the one to remember, because it is Windows, where +Go reads `%USERPROFILE%\_netrc` and not `.netrc`. The docker builds pass it on as `--secret id=netrc`, never a build arg. **`RELEASE_TOKEN` needs read access to the `vantage` org** on top of its existing scopes; without it every Go build fails at `go mod download` with a 404 on the module, which reads like a @@ -1367,7 +1371,7 @@ a build arg is baked into the image. So does anything that leaves no trustworthy base commit: a manual `workflow_dispatch`, a new branch, or a force-push whose old head is gone. -The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `ADMIN_API_URL`, `HQ_URL` or `ADMIN_ENV`, run the workflow manually — that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that. +The gap this leaves: **changing a repo variable pushes no commit, so nothing rebuilds.** After editing `HQ_URL`, run the workflow manually — that is what `workflow_dispatch` is there for. Base images also stop being refreshed on a service nobody touches; a periodic manual run covers that. ### `chart-release.yml` — validates on every chart change, publishes on `chart/v*` tags @@ -1385,11 +1389,14 @@ helm install vantage vantage/vantage --version 0.1.0 ### Tagging ```bash -git tag agent/v1.0.0 && git push origin agent/v1.0.0 # agent release -git tag chart/v0.1.0 && git push origin chart/v0.1.0 # helm chart package -git push origin main # server + web deploy +git tag chart/v0.1.0 && git push origin chart/v0.1.0 # helm chart package +git tag vantagectl/v0.1.0 && git push origin vantagectl/v0.1.0 # vantagectl release +git push origin main # server + web deploy ``` +The agent is tagged in `vantage-agent`, still as `agent/v*` — that prefix is +what the control plane greps release tag names for, so it survived the move. + ### Secrets / variables | Name | Type | Value | diff --git a/agent/cmd/main.go b/agent/cmd/main.go deleted file mode 100644 index f26bfa1..0000000 --- a/agent/cmd/main.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "context" - "flag" - "log" - "os/signal" - "syscall" - - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config" - agentsync "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/sync" -) - -var Version = "dev" - -func main() { - genKey := flag.String("generate-key", "", "Generate SSH keypair and upload with this label") - flag.Parse() - - cfg, err := config.Load() - if err != nil { - log.Fatalf("failed to load config: %v", err) - } - - if *genKey != "" { - if err := agentsync.GenerateAndUpload(cfg, *genKey); err != nil { - log.Fatalf("key generation failed: %v", err) - } - return - } - - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - log.Printf("vantage-agent %s starting (server=%s, poll=%s)", Version, cfg.ServerURL, cfg.PollInterval) - if err := agentsync.Run(ctx, cfg, Version); err != nil { - log.Fatalf("agent error: %v", err) - } -} diff --git a/agent/go.mod b/agent/go.mod deleted file mode 100644 index b1da6f8..0000000 --- a/agent/go.mod +++ /dev/null @@ -1,17 +0,0 @@ -module gitea.hostxtra.co.uk/mrhid6/vantage/agent - -go 1.26 - -require ( - golang.org/x/sys v0.47.0 - google.golang.org/grpc v1.64.0 - gopkg.in/yaml.v3 v3.0.1 -) - -require ( - gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 - golang.org/x/net v0.56.0 // indirect - golang.org/x/text v0.40.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect - google.golang.org/protobuf v1.34.1 // indirect -) diff --git a/agent/go.sum b/agent/go.sum deleted file mode 100644 index 1270524..0000000 --- a/agent/go.sum +++ /dev/null @@ -1,20 +0,0 @@ -gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0 h1:H6PCb8JHucrRiqPe9kGOhXUjBD66tKFHCP3qz5TjdZc= -gitea.hostxtra.co.uk/vantage/vantage-shared v0.1.0/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e h1:Elxv5MwEkCI9f5SkoL6afed6NTdxaGoAo39eANBwHL8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= -google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= -google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/agent/internal/checker/checker.go b/agent/internal/checker/checker.go deleted file mode 100644 index 392b046..0000000 --- a/agent/internal/checker/checker.go +++ /dev/null @@ -1,227 +0,0 @@ -package checker - -import ( - "context" - "crypto/tls" - "fmt" - "io" - "net" - "net/http" - "os" - "strings" - "time" -) - - -const ( - TypeHTTP = "http" - TypeTCP = "tcp" - TypeICMP = "icmp" - TypeTLS = "tls" - - // UserAgent identifies Vantage monitor traffic so a WAF rule can single it - // out. Match on a prefix, not equality: the version moves. - UserAgent = "Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)" -) - - -type Spec struct { - Type string - URL string - Host string - Port int - Method string - ExpectedStatus int - Keyword string - TLSWarnDays int - Insecure bool - TimeoutSec int -} - - -type Result struct { - Up bool - LatencyMs int - Message string - CertExpiry *time.Time -} - -func (s Spec) timeout() time.Duration { - t := s.TimeoutSec - if t <= 0 || t > 10 { - t = 10 - } - return time.Duration(t) * time.Second -} - - -func Run(ctx context.Context, s Spec) Result { - switch s.Type { - case TypeHTTP: - return runHTTP(ctx, s) - case TypeTCP: - return runTCP(ctx, s) - case TypeICMP: - return runICMP(ctx, s) - case TypeTLS: - return runTLS(ctx, s) - default: - return Result{Message: "unknown check type: " + s.Type} - } -} - -func runHTTP(ctx context.Context, s Spec) Result { - method := s.Method - if method == "" { - method = http.MethodGet - } - expect := s.ExpectedStatus - if expect == 0 { - expect = 200 - } - client := &http.Client{Timeout: s.timeout()} - if s.Insecure { - client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} - } - start := time.Now() - req, err := http.NewRequestWithContext(ctx, method, s.URL, nil) - if err != nil { - return Result{Message: err.Error()} - } - req.Header.Set("User-Agent", UserAgent) - resp, err := client.Do(req) - if err != nil { - return Result{LatencyMs: msSince(start), Message: err.Error()} - } - defer resp.Body.Close() - res := Result{LatencyMs: msSince(start), Up: true} - if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 { - exp := resp.TLS.PeerCertificates[0].NotAfter - res.CertExpiry = &exp - } - if resp.StatusCode != expect { - return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: fmt.Sprintf("status %d (want %d)", resp.StatusCode, expect)} - } - if s.Keyword != "" { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if !strings.Contains(string(body), s.Keyword) { - return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: "keyword not found"} - } - } - return res -} - -func runTCP(ctx context.Context, s Spec) Result { - addr := net.JoinHostPort(s.Host, fmt.Sprint(s.Port)) - start := time.Now() - d := net.Dialer{Timeout: s.timeout()} - conn, err := d.DialContext(ctx, "tcp", addr) - if err != nil { - return Result{LatencyMs: msSince(start), Message: err.Error()} - } - conn.Close() - return Result{Up: true, LatencyMs: msSince(start)} -} - -func runTLS(ctx context.Context, s Spec) Result { - port := s.Port - if port == 0 { - port = 443 - } - addr := net.JoinHostPort(s.Host, fmt.Sprint(port)) - start := time.Now() - d := net.Dialer{Timeout: s.timeout()} - conn, err := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: s.Host}) - if err != nil { - return Result{LatencyMs: msSince(start), Message: err.Error()} - } - defer conn.Close() - certs := conn.ConnectionState().PeerCertificates - if len(certs) == 0 { - return Result{LatencyMs: msSince(start), Message: "no peer certificate"} - } - exp := certs[0].NotAfter - res := Result{LatencyMs: msSince(start), CertExpiry: &exp} - warn := s.TLSWarnDays - if warn <= 0 { - warn = 14 - } - remaining := time.Until(exp) - if remaining <= 0 { - res.Message = "certificate expired" - return res - } - if remaining <= time.Duration(warn)*24*time.Hour { - res.Message = fmt.Sprintf("certificate expires in %d days", int(remaining.Hours()/24)) - return res - } - res.Up = true - return res -} - -func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) } - - - - -func runICMP(ctx context.Context, s Spec) Result { - dst, err := net.ResolveIPAddr("ip4", s.Host) - if err != nil { - return Result{Message: err.Error()} - } - conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0") - if err != nil { - return Result{Message: "icmp socket: " + err.Error()} - } - defer conn.Close() - - id := os.Getpid() & 0xffff - pkt := icmpEcho(id, 1) - deadline := time.Now().Add(s.timeout()) - if d, ok := ctx.Deadline(); ok && d.Before(deadline) { - deadline = d - } - _ = conn.SetDeadline(deadline) - - start := time.Now() - if _, err := conn.WriteTo(pkt, dst); err != nil { - return Result{Message: err.Error()} - } - reply := make([]byte, 1500) - for { - n, peer, err := conn.ReadFrom(reply) - if err != nil { - return Result{LatencyMs: msSince(start), Message: "no reply"} - } - - if n < 28 || peer.String() != dst.String() { - continue - } - if reply[20] == 0 { - return Result{Up: true, LatencyMs: msSince(start)} - } - } -} - -func icmpEcho(id, seq int) []byte { - - b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)} - cs := icmpChecksum(b) - b[2] = byte(cs >> 8) - b[3] = byte(cs) - return b -} - -func icmpChecksum(b []byte) uint16 { - var sum uint32 - for i := 0; i < len(b)-1; i += 2 { - sum += uint32(b[i])<<8 | uint32(b[i+1]) - } - if len(b)%2 == 1 { - sum += uint32(b[len(b)-1]) << 8 - } - for sum>>16 != 0 { - sum = (sum & 0xffff) + (sum >> 16) - } - return ^uint16(sum) -} diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go deleted file mode 100644 index c238722..0000000 --- a/agent/internal/config/config.go +++ /dev/null @@ -1,59 +0,0 @@ -package config - -import ( - "os" - "path/filepath" - "runtime" - "time" - - "gopkg.in/yaml.v3" -) - - -func ConfigDir() string { - if runtime.GOOS == "windows" { - base := os.Getenv("ProgramData") - if base == "" { - base = `C:\ProgramData` - } - return filepath.Join(base, "vantage") - } - return "/etc/vantage" -} - -func configPath() string { return filepath.Join(ConfigDir(), "config.yaml") } - -type Config struct { - ServerURL string `yaml:"server_url"` - ServerID string `yaml:"server_id"` - PreRegToken string `yaml:"pre_reg_token"` - AgentToken string `yaml:"agent_token"` - PollInterval time.Duration `yaml:"poll_interval"` - TLS bool `yaml:"tls"` -} - -func Load() (*Config, error) { - data, err := os.ReadFile(configPath()) - if err != nil { - return nil, err - } - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, err - } - if cfg.PollInterval == 0 { - cfg.PollInterval = 30 * time.Second - } - return &cfg, nil -} - -func Save(cfg *Config) error { - data, err := yaml.Marshal(cfg) - if err != nil { - return err - } - if err := os.MkdirAll(ConfigDir(), 0700); err != nil { - return err - } - return os.WriteFile(configPath(), data, 0600) -} diff --git a/agent/internal/exec/exec.go b/agent/internal/exec/exec.go deleted file mode 100644 index 10221b3..0000000 --- a/agent/internal/exec/exec.go +++ /dev/null @@ -1,147 +0,0 @@ -package exec - -import ( - "bufio" - "context" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "sync" - "time" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" -) - -type streamWriter struct { - mu sync.Mutex - seq uint64 - emit func(seq uint64, data []byte) -} - -func (w *streamWriter) Write(p []byte) (int, error) { - w.mu.Lock() - defer w.mu.Unlock() - if w.emit != nil { - buf := make([]byte, len(p)) - copy(buf, p) - w.emit(w.seq, buf) - w.seq++ - } - return len(p), nil -} - -func WorkspacePath(workspaceID string) string { - return filepath.Join(os.TempDir(), "vantage-run-"+workspaceID) -} - -func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult { - res := &pb.StepResult{CommandId: "", OutputEnv: map[string]string{}} - - dir, err := os.MkdirTemp("", "vantage-step-") - if err != nil { - res.ExitCode = 1 - res.Stderr = "create temp dir: " + err.Error() - return res - } - defer os.RemoveAll(dir) - - workDir := "" - if cmd.WorkspaceId != "" { - workDir = WorkspacePath(cmd.WorkspaceId) - if err := os.MkdirAll(workDir, 0700); err != nil { - res.ExitCode = 1 - res.Stderr = "create workspace: " + err.Error() - return res - } - } - - envFile := filepath.Join(dir, "workflow_env") - if err := os.WriteFile(envFile, nil, 0600); err != nil { - res.ExitCode = 1 - res.Stderr = "create env file: " + err.Error() - return res - } - - var scriptPath string - var c *exec.Cmd - timeout := time.Duration(cmd.TimeoutSeconds) * time.Second - if timeout <= 0 { - timeout = 30 * time.Minute - } - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - switch cmd.Interpreter { - case "powershell": - scriptPath = filepath.Join(dir, "step.ps1") - if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0600); err != nil { - res.ExitCode = 1 - res.Stderr = err.Error() - return res - } - shell := "pwsh" - if runtime.GOOS == "windows" { - if _, err := exec.LookPath("pwsh"); err != nil { - shell = "powershell.exe" - } - } - c = exec.CommandContext(ctx, shell, "-NoProfile", "-NonInteractive", "-File", scriptPath) - default: - scriptPath = filepath.Join(dir, "step.sh") - if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0700); err != nil { - res.ExitCode = 1 - res.Stderr = err.Error() - return res - } - c = exec.CommandContext(ctx, "bash", scriptPath) - } - - if workDir != "" { - c.Dir = workDir - } - - c.Env = append(os.Environ(), "WORKFLOW_ENV="+envFile) - for k, v := range cmd.Env { - c.Env = append(c.Env, k+"="+v) - } - - sw := &streamWriter{emit: emit} - c.Stdout = sw - c.Stderr = sw - runErr := c.Run() - - if ctx.Err() == context.DeadlineExceeded { - res.ExitCode = 124 - res.Stderr = "[vantage] step timed out" - } else if ee, ok := runErr.(*exec.ExitError); ok { - res.ExitCode = ee.ExitCode() - } else if runErr != nil { - res.ExitCode = 1 - res.Stderr = "[vantage] " + runErr.Error() - } - - res.OutputEnv = parseEnvFile(envFile) - return res -} - -func parseEnvFile(path string) map[string]string { - out := map[string]string{} - f, err := os.Open(path) - if err != nil { - return out - } - defer f.Close() - sc := bufio.NewScanner(f) - sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for sc.Scan() { - line := sc.Text() - i := strings.IndexByte(line, '=') - if i <= 0 { - continue - } - out[line[:i]] = line[i+1:] - } - return out -} diff --git a/agent/internal/grpc/client.go b/agent/internal/grpc/client.go deleted file mode 100644 index 1e68984..0000000 --- a/agent/internal/grpc/client.go +++ /dev/null @@ -1,189 +0,0 @@ -package grpcclient - -import ( - "context" - "crypto/tls" - "strings" - "time" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/codec" - "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/encoding" - "google.golang.org/grpc/keepalive" -) - -func init() { - encoding.RegisterCodec(codec.JSONCodec{}) -} - -type Client struct { - conn *grpc.ClientConn - client pb.VantageClient -} - -func New(serverURL string, useTLS bool) (*Client, error) { - serverURL = strings.TrimPrefix(serverURL, "https://") - serverURL = strings.TrimPrefix(serverURL, "http://") - - dialOpts := []grpc.DialOption{ - grpc.WithKeepaliveParams(keepalive.ClientParameters{ - Time: 30 * time.Second, - Timeout: 10 * time.Second, - PermitWithoutStream: false, - }), - } - - if useTLS { - tlsCfg := &tls.Config{ - InsecureSkipVerify: false, - } - creds := credentials.NewTLS(tlsCfg) - dialOpts = append(dialOpts, grpc.WithTransportCredentials(creds)) - } else { - dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) - } - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - conn, err := grpc.DialContext(ctx, serverURL, dialOpts...) - if err != nil { - return nil, err - } - - return &Client{ - conn: conn, - client: pb.NewVantageClient(conn), - }, nil -} - -func (c *Client) Close() error { - return c.conn.Close() -} - -func (c *Client) Register(serverID, preRegToken, hostname, ipAddress, osInfo string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - resp, err := c.client.Register(ctx, &pb.RegisterRequest{ - ServerId: serverID, - PreRegToken: preRegToken, - Hostname: hostname, - IpAddress: ipAddress, - OsInfo: osInfo, - }) - if err != nil { - return "", err - } - return resp.AgentToken, nil -} - -// SyncKeys returns the whole response rather than just the keys: the poll now -// also carries CollectPackages, and a second RPC purely to learn one boolean -// would be a message every 30 seconds for a value that changes at most when a -// licence does. -func (c *Client) SyncKeys(serverID, agentToken, version string) (*pb.SyncResponse, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - resp, err := c.client.SyncKeys(ctx, &pb.SyncRequest{ - ServerId: serverID, - AgentToken: agentToken, - AgentVersion: version, - }) - if err != nil { - return nil, err - } - return resp, nil -} - -// ReportPackages sends a package report and returns whether the server wants -// the full list. Given a longer deadline than the other unary calls because the -// full body is ~150KB on a slow link. -func (c *Client) ReportPackages(req *pb.ReportPackagesRequest) (bool, error) { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - resp, err := c.client.ReportPackages(ctx, req) - if err != nil { - return false, err - } - return resp.NeedFull, nil -} - -// ReportWorkloads sends a workload report and returns whether the server wants -// the full list. -func (c *Client) ReportWorkloads(req *pb.ReportWorkloadsRequest) (bool, error) { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - resp, err := c.client.ReportWorkloads(ctx, req) - if err != nil { - return false, err - } - return resp.NeedFull, nil -} - -func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey, label string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - resp, err := c.client.UploadGeneratedKey(ctx, &pb.UploadKeyRequest{ - ServerId: serverID, - AgentToken: agentToken, - PublicKey: publicKey, - PrivateKey: privateKey, - Label: label, - }) - if err != nil { - return "", err - } - return resp.KeyId, nil -} - -func (c *Client) ReportUpdates(serverID, agentToken string, updates []pb.PackageUpdate) error { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - _, err := c.client.ReportUpdates(ctx, &pb.ReportUpdatesRequest{ - ServerId: serverID, - AgentToken: agentToken, - Updates: updates, - }) - return err -} - -func (c *Client) ReportInventory(report *pb.InventoryReport) error { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _, err := c.client.ReportInventory(ctx, report) - return err -} - -func (c *Client) SyncMonitors(serverID, agentToken string) ([]pb.MonitorSpec, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - resp, err := c.client.SyncMonitors(ctx, &pb.SyncMonitorsRequest{ServerId: serverID, AgentToken: agentToken}) - if err != nil { - return nil, err - } - return resp.Monitors, nil -} - -func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckResult) error { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - _, err := c.client.ReportChecks(ctx, &pb.ReportChecksRequest{ServerId: serverID, AgentToken: agentToken, Results: results}) - return err -} - -func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) { - return c.client.CommandStream(ctx) -} - -func (c *Client) ProxyStream(ctx context.Context) (pb.Vantage_ProxyStreamClient, error) { - return c.client.ProxyStream(ctx) -} diff --git a/agent/internal/inventory/collect_linux.go b/agent/internal/inventory/collect_linux.go deleted file mode 100644 index d2bbeb0..0000000 --- a/agent/internal/inventory/collect_linux.go +++ /dev/null @@ -1,158 +0,0 @@ -package inventory - -import ( - "bufio" - "os" - "strconv" - "strings" - "syscall" - "time" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" -) - -func collect(r *pb.InventoryReport, includeStatic bool) { - r.CPU.UsagePct = cpuUsage() - r.CPU.Load1 = load1() - memTotal, memAvail, swapTotal, swapFree := meminfo() - if memTotal > memAvail { - r.Memory.UsedBytes = memTotal - memAvail - } - if swapTotal > swapFree { - r.SwapUsed = swapTotal - swapFree - } - if includeStatic { - r.Memory.TotalBytes = memTotal - r.SwapTotal = swapTotal - r.CPU.Model, r.CPU.Cores = cpuStatic() - r.Kernel = kernel() - r.Partitions = partitions() - } -} - -func readProc(path string) string { b, _ := os.ReadFile(path); return string(b) } - -func cpuSample() (idle, total uint64) { - f, err := os.Open("/proc/stat") - if err != nil { - return - } - defer f.Close() - sc := bufio.NewScanner(f) - if sc.Scan() { - fields := strings.Fields(sc.Text()) - for i, v := range fields[1:] { - n, _ := strconv.ParseUint(v, 10, 64) - total += n - if i == 3 { - idle = n - } - } - } - return -} - -func cpuUsage() float64 { - i1, t1 := cpuSample() - time.Sleep(100 * time.Millisecond) - i2, t2 := cpuSample() - dt := float64(t2 - t1) - if dt <= 0 { - return 0 - } - return (1 - float64(i2-i1)/dt) * 100 -} - -func load1() float64 { - fields := strings.Fields(readProc("/proc/loadavg")) - if len(fields) > 0 { - v, _ := strconv.ParseFloat(fields[0], 64) - return v - } - return 0 -} - -func meminfo() (total, avail, swapTotal, swapFree uint64) { - f, err := os.Open("/proc/meminfo") - if err != nil { - return - } - defer f.Close() - sc := bufio.NewScanner(f) - for sc.Scan() { - fields := strings.Fields(sc.Text()) - if len(fields) < 2 { - continue - } - kb, _ := strconv.ParseUint(fields[1], 10, 64) - b := kb * 1024 - switch strings.TrimSuffix(fields[0], ":") { - case "MemTotal": - total = b - case "MemAvailable": - avail = b - case "SwapTotal": - swapTotal = b - case "SwapFree": - swapFree = b - } - } - return -} - -func cpuStatic() (model string, cores int) { - f, err := os.Open("/proc/cpuinfo") - if err != nil { - return - } - defer f.Close() - sc := bufio.NewScanner(f) - for sc.Scan() { - line := sc.Text() - if strings.HasPrefix(line, "processor") { - cores++ - } else if strings.HasPrefix(line, "model name") && model == "" { - if i := strings.Index(line, ":"); i >= 0 { - model = strings.TrimSpace(line[i+1:]) - } - } - } - return -} - -func kernel() string { - return strings.TrimSpace(readProc("/proc/sys/kernel/osrelease")) -} - -func partitions() []pb.PartitionReport { - allowed := map[string]bool{"ext4": true, "xfs": true, "btrfs": true, "zfs": true, "vfat": true, "ntfs": true, "ext3": true} - f, err := os.Open("/proc/mounts") - if err != nil { - return nil - } - defer f.Close() - var out []pb.PartitionReport - seen := map[string]bool{} - sc := bufio.NewScanner(f) - for sc.Scan() { - fields := strings.Fields(sc.Text()) - if len(fields) < 3 || !allowed[fields[2]] || seen[fields[1]] { - continue - } - seen[fields[1]] = true - var st syscall.Statfs_t - if syscall.Statfs(fields[1], &st) != nil { - continue - } - bsize := uint64(st.Bsize) - total := st.Blocks * bsize - // Bfree, not Bavail: the difference is the root-reserved 5% on ext4, - // which is not used space. df counts it the same way. - used := (st.Blocks - st.Bfree) * bsize - out = append(out, pb.PartitionReport{ - Device: fields[0], Mountpoint: fields[1], Fstype: fields[2], - TotalBytes: total, UsedBytes: used, - }) - } - return out -} diff --git a/agent/internal/inventory/collect_other.go b/agent/internal/inventory/collect_other.go deleted file mode 100644 index d66cc9a..0000000 --- a/agent/internal/inventory/collect_other.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build !linux && !windows - -// Inventory collection has Linux and Windows implementations. This no-op stands -// in everywhere else. -// -// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so -// without it this file compiles on Linux too and collides with collect_linux.go. -package inventory - -import "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" - -func collect(r *pb.InventoryReport, includeStatic bool) {} diff --git a/agent/internal/inventory/collect_windows.go b/agent/internal/inventory/collect_windows.go deleted file mode 100644 index 5226592..0000000 --- a/agent/internal/inventory/collect_windows.go +++ /dev/null @@ -1,183 +0,0 @@ -package inventory - -import ( - "fmt" - "runtime" - "time" - "unsafe" - - "golang.org/x/sys/windows" - "golang.org/x/sys/windows/registry" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" -) - -var ( - kernel32 = windows.NewLazySystemDLL("kernel32.dll") - procGetSystemTimes = kernel32.NewProc("GetSystemTimes") - // x/sys/windows exposes neither of these two, so they are bound by hand. - procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx") -) - -func collect(r *pb.InventoryReport, includeStatic bool) { - r.CPU.UsagePct = cpuUsage() - // Windows has no load average. Left at zero; the UI already treats it as - // optional because it is omitempty on the wire. - - m := memoryStatus() - if m.TotalPhys > m.AvailPhys { - r.Memory.UsedBytes = m.TotalPhys - m.AvailPhys - } - // TotalPageFile is the commit limit — physical memory plus the pagefile — - // so the pagefile alone is the difference. - swapTotal := sub(m.TotalPageFile, m.TotalPhys) - swapUsed := sub(sub(m.TotalPageFile, m.AvailPageFile), sub(m.TotalPhys, m.AvailPhys)) - if swapUsed > swapTotal { - swapUsed = swapTotal - } - r.SwapUsed = swapUsed - - if includeStatic { - r.Memory.TotalBytes = m.TotalPhys - r.SwapTotal = swapTotal - r.CPU.Model, r.CPU.Cores = cpuStatic() - r.Kernel = kernel() - r.Partitions = partitions() - } -} - -func sub(a, b uint64) uint64 { - if a > b { - return a - b - } - return 0 -} - -type memoryStatusEx struct { - Length uint32 - MemoryLoad uint32 - TotalPhys uint64 - AvailPhys uint64 - TotalPageFile uint64 - AvailPageFile uint64 - TotalVirtual uint64 - AvailVirtual uint64 - AvailExtendedVirtual uint64 -} - -func memoryStatus() memoryStatusEx { - var m memoryStatusEx - m.Length = uint32(unsafe.Sizeof(m)) - r, _, _ := procGlobalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&m))) - if r == 0 { - return memoryStatusEx{} - } - return m -} - -func systemTimes() (idle, total uint64, ok bool) { - var idleFT, kernelFT, userFT windows.Filetime - r, _, _ := procGetSystemTimes.Call( - uintptr(unsafe.Pointer(&idleFT)), - uintptr(unsafe.Pointer(&kernelFT)), - uintptr(unsafe.Pointer(&userFT)), - ) - if r == 0 { - return 0, 0, false - } - ft := func(f windows.Filetime) uint64 { - return uint64(f.HighDateTime)<<32 | uint64(f.LowDateTime) - } - // Kernel time already includes idle time, so kernel+user is the whole. - return ft(idleFT), ft(kernelFT) + ft(userFT), true -} - -func cpuUsage() float64 { - i1, t1, ok := systemTimes() - if !ok { - return 0 - } - time.Sleep(100 * time.Millisecond) - i2, t2, ok := systemTimes() - if !ok || t2 <= t1 { - return 0 - } - return (1 - float64(i2-i1)/float64(t2-t1)) * 100 -} - -func cpuStatic() (model string, cores int) { - cores = runtime.NumCPU() - k, err := registry.OpenKey(registry.LOCAL_MACHINE, - `HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE) - if err != nil { - return - } - defer k.Close() - if s, _, err := k.GetStringValue("ProcessorNameString"); err == nil { - model = s - } - return -} - -func kernel() string { - v := windows.RtlGetVersion() - return fmt.Sprintf("%d.%d.%d", v.MajorVersion, v.MinorVersion, v.BuildNumber) -} - -func partitions() []pb.PartitionReport { - buf := make([]uint16, 256) - n, err := windows.GetLogicalDriveStrings(uint32(len(buf)), &buf[0]) - if err != nil || n == 0 { - return nil - } - - var out []pb.PartitionReport - for _, root := range splitNullStrings(buf[:n]) { - rootPtr, err := windows.UTF16PtrFromString(root) - if err != nil { - continue - } - // Fixed disks only: network shares can hang, and removable drives - // would appear and vanish between snapshots. - if windows.GetDriveType(rootPtr) != windows.DRIVE_FIXED { - continue - } - - var free, total, totalFree uint64 - if err := windows.GetDiskFreeSpaceEx(rootPtr, &free, &total, &totalFree); err != nil { - continue - } - - fsBuf := make([]uint16, 32) - var fstype string - if err := windows.GetVolumeInformation(rootPtr, nil, 0, nil, nil, nil, &fsBuf[0], uint32(len(fsBuf))); err == nil { - fstype = windows.UTF16ToString(fsBuf) - } - - out = append(out, pb.PartitionReport{ - Device: root, - Mountpoint: root, - Fstype: fstype, - TotalBytes: total, - UsedBytes: total - totalFree, - }) - } - return out -} - -// splitNullStrings splits the NUL-separated, double-NUL-terminated block that -// GetLogicalDriveStrings writes. -func splitNullStrings(b []uint16) []string { - var out []string - start := 0 - for i, c := range b { - if c != 0 { - continue - } - if i > start { - out = append(out, windows.UTF16ToString(b[start:i])) - } - start = i + 1 - } - return out -} diff --git a/agent/internal/inventory/inventory.go b/agent/internal/inventory/inventory.go deleted file mode 100644 index de5270b..0000000 --- a/agent/internal/inventory/inventory.go +++ /dev/null @@ -1,9 +0,0 @@ -package inventory - -import "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" - -func Collect(includeStatic bool) *pb.InventoryReport { - r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}} - collect(r, includeStatic) - return r -} diff --git a/agent/internal/keys/keys.go b/agent/internal/keys/keys.go deleted file mode 100644 index 674fc3c..0000000 --- a/agent/internal/keys/keys.go +++ /dev/null @@ -1,228 +0,0 @@ -package keys - -import ( - "crypto/md5" - "encoding/base64" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" -) - -const authorizedKeysPath = "/root/.ssh/authorized_keys" -const sshConfigPath = "/root/.ssh/config" -const managedConfigPath = "/root/.ssh/vantage.conf" -const includeDirective = "Include /root/.ssh/vantage.conf" - -func ReadAuthorizedKeys() ([]string, error) { - data, err := os.ReadFile(authorizedKeysPath) - if os.IsNotExist(err) { - return nil, nil - } - if err != nil { - return nil, err - } - - var lines []string - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if line != "" && !strings.HasPrefix(line, "#") { - lines = append(lines, line) - } - } - return lines, nil -} - -func WriteAuthorizedKeys(keys []string) error { - dir := filepath.Dir(authorizedKeysPath) - if err := os.MkdirAll(dir, 0700); err != nil { - return fmt.Errorf("mkdir %s: %w", dir, err) - } - - content := strings.Join(keys, "\n") - if len(keys) > 0 { - content += "\n" - } - - tmpPath := authorizedKeysPath + ".tmp" - if err := os.WriteFile(tmpPath, []byte(content), 0600); err != nil { - return fmt.Errorf("write tmp: %w", err) - } - - if err := os.Rename(tmpPath, authorizedKeysPath); err != nil { - os.Remove(tmpPath) - return fmt.Errorf("rename: %w", err) - } - - return os.Chmod(authorizedKeysPath, 0600) -} - -func FingerprintLines(lines []string) map[string]bool { - fp := make(map[string]bool, len(lines)) - for _, line := range lines { - fp[fingerprint(line)] = true - } - return fp -} - -func StateChanged(current, desired []string) bool { - if len(current) != len(desired) { - return true - } - cur := FingerprintLines(current) - for _, line := range desired { - if !cur[fingerprint(line)] { - return true - } - } - return false -} - -func fingerprint(pubKey string) string { - parts := strings.Fields(pubKey) - if len(parts) < 2 { - return pubKey - } - raw, err := base64.StdEncoding.DecodeString(parts[1]) - if err != nil { - return pubKey - } - sum := md5.Sum(raw) - var pairs []string - for _, b := range sum { - pairs = append(pairs, fmt.Sprintf("%02x", b)) - } - return "MD5:" + strings.Join(pairs, ":") -} - - -type KeyGenOptions struct { - KeyType string - KeySize int - Passphrase string - Comment string -} - - - -func GenerateKeyPair(keyPath string, opts KeyGenOptions) (string, error) { - if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil { - return "", err - } - - keyType := opts.KeyType - if keyType == "" { - keyType = "ed25519" - } - - args := []string{ - "-t", keyType, - "-f", keyPath, - "-N", opts.Passphrase, - "-C", opts.Comment, - } - if opts.KeySize > 0 && keyType != "ed25519" { - args = append(args, "-b", fmt.Sprintf("%d", opts.KeySize)) - } - - cmd := exec.Command("ssh-keygen", args...) - out, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("ssh-keygen: %w: %s", err, out) - } - - pubData, err := os.ReadFile(keyPath + ".pub") - if err != nil { - return "", fmt.Errorf("read pubkey: %w", err) - } - return strings.TrimSpace(string(pubData)), nil -} - - - -func AddSSHIdentity(keyPath string) error { - if err := os.MkdirAll(filepath.Dir(sshConfigPath), 0700); err != nil { - return fmt.Errorf("mkdir .ssh: %w", err) - } - - if err := ensureIncludeDirective(); err != nil { - return err - } - - - var existing string - data, err := os.ReadFile(managedConfigPath) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("read %s: %w", managedConfigPath, err) - } - existing = string(data) - - line := "IdentityFile " + keyPath - for _, l := range strings.Split(existing, "\n") { - if strings.TrimSpace(l) == line { - return nil - } - } - - if existing != "" && !strings.HasSuffix(existing, "\n") { - existing += "\n" - } - updated := existing + line + "\n" - - if err := os.WriteFile(managedConfigPath, []byte(updated), 0600); err != nil { - return fmt.Errorf("write %s: %w", managedConfigPath, err) - } - return nil -} - - -func RemoveSSHIdentity(keyPath string) error { - data, err := os.ReadFile(managedConfigPath) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return fmt.Errorf("read %s: %w", managedConfigPath, err) - } - - line := "IdentityFile " + keyPath - var kept []string - for _, l := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") { - if strings.TrimSpace(l) != line { - kept = append(kept, l) - } - } - - content := strings.Join(kept, "\n") - if len(kept) > 0 { - content += "\n" - } - if err := os.WriteFile(managedConfigPath, []byte(content), 0600); err != nil { - return fmt.Errorf("write %s: %w", managedConfigPath, err) - } - return nil -} - - - - -func ensureIncludeDirective() error { - data, err := os.ReadFile(sshConfigPath) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("read %s: %w", sshConfigPath, err) - } - - for _, l := range strings.Split(string(data), "\n") { - if strings.TrimSpace(l) == includeDirective { - return nil - } - } - - - updated := includeDirective + "\n" + string(data) - if err := os.WriteFile(sshConfigPath, []byte(updated), 0600); err != nil { - return fmt.Errorf("write %s: %w", sshConfigPath, err) - } - return nil -} diff --git a/agent/internal/monitors/monitors.go b/agent/internal/monitors/monitors.go deleted file mode 100644 index 5501e1d..0000000 --- a/agent/internal/monitors/monitors.go +++ /dev/null @@ -1,158 +0,0 @@ -package monitors - -import ( - "context" - "log" - "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-shared/grpc/pb" -) - -const syncInterval = 30 * time.Second - -type runner struct { - intervalSec int - cancel context.CancelFunc -} - -func Run(ctx context.Context, cfg *config.Config) { - active := map[string]*runner{} - var mu sync.Mutex - - results := make(chan pb.CheckResult, 64) - go reporter(ctx, cfg, results) - - syncOnce := func() { - specs, err := fetchSpecs(cfg) - if err != nil { - log.Printf("monitors: sync: %v", err) - return - } - want := map[string]pb.MonitorSpec{} - for _, s := range specs { - want[s.MonitorId] = s - } - - mu.Lock() - defer mu.Unlock() - for id, r := range active { - s, ok := want[id] - if !ok || s.IntervalSec != r.intervalSec { - r.cancel() - delete(active, id) - } - } - for id, s := range want { - if _, ok := active[id]; ok { - continue - } - rctx, cancel := context.WithCancel(ctx) - active[id] = &runner{intervalSec: s.IntervalSec, cancel: cancel} - go runSpec(rctx, s, results) - } - } - - syncOnce() - t := time.NewTicker(syncInterval) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - syncOnce() - } - } -} - -func fetchSpecs(cfg *config.Config) ([]pb.MonitorSpec, error) { - client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) - if err != nil { - return nil, err - } - defer client.Close() - return client.SyncMonitors(cfg.ServerID, cfg.AgentToken) -} - -func runSpec(ctx context.Context, s pb.MonitorSpec, out chan<- pb.CheckResult) { - interval := time.Duration(s.IntervalSec) * time.Second - if interval <= 0 { - interval = 60 * time.Second - } - spec := checker.Spec{ - Type: s.Type, - URL: s.URL, - Host: s.Host, - Port: s.Port, - Method: s.Method, - ExpectedStatus: s.ExpectedStatus, - Keyword: s.Keyword, - TLSWarnDays: s.TLSWarnDays, - Insecure: s.Insecure, - TimeoutSec: s.IntervalSec, - } - - run := func() { - res := checker.Run(ctx, spec) - cr := pb.CheckResult{MonitorId: s.MonitorId, Up: res.Up, LatencyMs: res.LatencyMs, Message: res.Message} - if res.CertExpiry != nil { - cr.CertExpiryUnix = res.CertExpiry.Unix() - } - select { - case out <- cr: - case <-ctx.Done(): - } - } - - run() - t := time.NewTicker(interval) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - run() - } - } -} - -func reporter(ctx context.Context, cfg *config.Config, in <-chan pb.CheckResult) { - t := time.NewTicker(5 * time.Second) - defer t.Stop() - var batch []pb.CheckResult - flush := func() { - if len(batch) == 0 { - return - } - client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) - if err != nil { - log.Printf("monitors: report dial: %v", err) - batch = nil - return - } - if err := client.ReportChecks(cfg.ServerID, cfg.AgentToken, batch); err != nil { - log.Printf("monitors: report: %v", err) - } - client.Close() - batch = nil - } - for { - select { - case <-ctx.Done(): - flush() - return - case r := <-in: - batch = append(batch, r) - if len(batch) >= 32 { - flush() - } - case <-t.C: - flush() - } - } -} diff --git a/agent/internal/packages/osrelease.go b/agent/internal/packages/osrelease.go deleted file mode 100644 index bdc2541..0000000 --- a/agent/internal/packages/osrelease.go +++ /dev/null @@ -1,63 +0,0 @@ -package packages - -import ( - "bufio" - "errors" - "io" - "os" - "runtime" - "strings" -) - -// OSRelease identifies the distribution well enough to select an advisory -// feed. VersionID is not optional: Ubuntu 22.04 and 24.04 publish different -// fixed versions for the same CVE. -type OSRelease struct { - Family string - VersionID string - Arch string -} - -// ParseOSRelease reads the os-release format: KEY=value, one per line, with -// values optionally quoted, and # comments. -// -// The quote stripping handles both ID=ubuntu and ID="rocky", which real -// distributions both emit. -func ParseOSRelease(r io.Reader) (OSRelease, error) { - out := OSRelease{Arch: runtime.GOARCH} - sc := bufio.NewScanner(r) - for sc.Scan() { - line := strings.TrimSpace(sc.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - key, val, ok := strings.Cut(line, "=") - if !ok { - continue - } - val = strings.Trim(strings.TrimSpace(val), `"'`) - switch strings.TrimSpace(key) { - case "ID": - out.Family = strings.ToLower(val) - case "VERSION_ID": - out.VersionID = val - } - } - if err := sc.Err(); err != nil { - return OSRelease{}, err - } - if out.Family == "" { - return OSRelease{}, errors.New("os-release has no ID") - } - return out, nil -} - -// DetectOS reads /etc/os-release. -func DetectOS() (OSRelease, error) { - f, err := os.Open("/etc/os-release") - if err != nil { - return OSRelease{}, err - } - defer f.Close() - return ParseOSRelease(f) -} diff --git a/agent/internal/packages/packages.go b/agent/internal/packages/packages.go deleted file mode 100644 index a503223..0000000 --- a/agent/internal/packages/packages.go +++ /dev/null @@ -1,73 +0,0 @@ -package packages - -import ( - "context" - "fmt" - "os/exec" - "runtime" - "time" -) - -const collectTimeout = 2 * time.Minute - -// Collect enumerates installed packages. Linux only: Windows agents are -// second-class by design, and vulnerability scanning there needs a different -// source, a different collector and a different matcher, all out of scope. -// -// The format strings below are raw string literals on purpose. The "\t" and -// "\n" reach dpkg-query and rpm as two characters each, and those tools do the -// interpreting themselves — Go must not consume the escapes first. -func Collect() (OSRelease, []Package, error) { - if runtime.GOOS != "linux" { - return OSRelease{}, nil, fmt.Errorf("package collection is linux-only, got %s", runtime.GOOS) - } - - osrel, err := DetectOS() - if err != nil { - return OSRelease{}, nil, fmt.Errorf("detect os: %w", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), collectTimeout) - defer cancel() - - switch { - case have("dpkg-query"): - out, err := run(ctx, "dpkg-query", "-W", "-f", - `${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n`) - if err != nil { - return osrel, nil, err - } - return osrel, ParseDpkg(out), nil - - case have("rpm"): - out, err := run(ctx, "rpm", "-qa", "--qf", - `%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n`) - if err != nil { - return osrel, nil, err - } - return osrel, ParseRPM(out), nil - - case have("apk"): - out, err := run(ctx, "apk", "info", "-v") - if err != nil { - return osrel, nil, err - } - return osrel, ParseAPK(out), nil - - default: - return osrel, nil, fmt.Errorf("no supported package manager found") - } -} - -func have(bin string) bool { - _, err := exec.LookPath(bin) - return err == nil -} - -func run(ctx context.Context, name string, args ...string) (string, error) { - out, err := exec.CommandContext(ctx, name, args...).Output() - if err != nil { - return "", fmt.Errorf("%s: %w", name, err) - } - return string(out), nil -} diff --git a/agent/internal/packages/parse.go b/agent/internal/packages/parse.go deleted file mode 100644 index 2365332..0000000 --- a/agent/internal/packages/parse.go +++ /dev/null @@ -1,159 +0,0 @@ -package packages - -import ( - "crypto/sha256" - "encoding/hex" - "sort" - "strconv" - "strings" -) - -// Package is one installed package as the distribution reports it. Version is -// the distribution's own version string, verbatim — never normalised, because -// the advisory feeds are keyed on exactly this form. -type Package struct { - Name string - Version string - Epoch int - Arch string - SourceName string -} - -// ParseDpkg reads tab-separated output of -// dpkg-query -W -f '${Package}\t${Version}\t${Architecture}\t${source:Package}\t${db:Status-Status}\n' -// -// SourceName is why the fourth column is requested at all: Debian and Ubuntu -// advisories are keyed on the SOURCE package, so one CVE against "openssl" -// covers the binaries libssl3, openssl and libssl-dev. Matching on binary name -// alone finds one of the three. -// -// The fifth column is why "rc" packages do not appear. dpkg-query -W lists -// every package dpkg knows about, including ones removed with their config -// files left behind — a host that has upgraded its kernel a dozen times reports -// a dozen old linux-modules versions that are not on disk, and the oldest of -// them sorts first and reads as the installed version. Only "installed" is -// installed. An empty status means dpkg did not understand the field, in which -// case the line is kept rather than the whole inventory silently vanishing. -func ParseDpkg(out string) []Package { - var pkgs []Package - for _, line := range strings.Split(out, "\n") { - if strings.TrimSpace(line) == "" { - continue - } - f := strings.Split(line, "\t") - if len(f) < 3 { - continue - } - if len(f) > 4 { - if s := strings.TrimSpace(f[4]); s != "" && s != "installed" { - continue - } - } - p := Package{Name: f[0], Version: f[1], Arch: f[2]} - if len(f) > 3 && f[3] != "" { - p.SourceName = f[3] - } else { - p.SourceName = p.Name - } - pkgs = append(pkgs, p) - } - return pkgs -} - -// ParseRPM reads tab-separated output of -// rpm -qa --qf '%{NAME}\t%{EPOCH}\t%{VERSION}-%{RELEASE}\t%{ARCH}\t%{SOURCERPM}\n' -func ParseRPM(out string) []Package { - var pkgs []Package - for _, line := range strings.Split(out, "\n") { - if strings.TrimSpace(line) == "" { - continue - } - f := strings.Split(line, "\t") - if len(f) < 4 { - continue - } - epoch := 0 - // rpm prints "(none)" rather than omitting the field when a package has - // no epoch. That must become 0, not fail the line. - if f[1] != "" && f[1] != "(none)" { - if n, err := strconv.Atoi(f[1]); err == nil { - epoch = n - } - } - p := Package{Name: f[0], Epoch: epoch, Version: f[2], Arch: f[3]} - if len(f) > 4 { - p.SourceName = srcRPMName(f[4]) - } - if p.SourceName == "" { - p.SourceName = p.Name - } - pkgs = append(pkgs, p) - } - return pkgs -} - -// srcRPMName reduces "openssl-3.0.7-24.el9.src.rpm" to "openssl" by dropping -// the trailing ".src.rpm" and then the version and release segments, which are -// the last two hyphen-separated fields. -func srcRPMName(s string) string { - s = strings.TrimSuffix(s, ".src.rpm") - parts := strings.Split(s, "-") - if len(parts) <= 2 { - return s - } - return strings.Join(parts[:len(parts)-2], "-") -} - -// ParseAPK reads "apk info -v" output: one "name-version-rREV" per line. -// Alpine has no separate source package, so SourceName mirrors Name. -func ParseAPK(out string) []Package { - var pkgs []Package - for _, line := range strings.Split(out, "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - name, version := splitAPK(line) - if name == "" { - continue - } - pkgs = append(pkgs, Package{Name: name, Version: version, SourceName: name}) - } - return pkgs -} - -// splitAPK finds the version boundary from the RIGHT. The version is always the -// last two hyphen-separated fields ("-r"), which is reliable -// where scanning from the left is not: package names legitimately contain -// digits and underscores, so "musl" in "musl-1.2.4_git20230717-r4" cannot be -// found by looking for the first digit. -func splitAPK(s string) (name, version string) { - last := strings.LastIndex(s, "-") - if last <= 0 { - return "", "" - } - prev := strings.LastIndex(s[:last], "-") - if prev <= 0 { - return "", "" - } - return s[:prev], s[prev+1:] -} - -// Hash fingerprints a package set so an unchanged set never has to be sent. -// -// It sorts first: the ordering of dpkg or rpm output is not guaranteed stable, -// and an ordering-sensitive hash would resend the full ~150KB list every hour -// for no reason — a cost visible only as traffic. -func Hash(pkgs []Package) string { - lines := make([]string, 0, len(pkgs)) - for _, p := range pkgs { - lines = append(lines, p.Name+"\x00"+strconv.Itoa(p.Epoch)+"\x00"+p.Version+"\x00"+p.Arch) - } - sort.Strings(lines) - h := sha256.New() - for _, l := range lines { - h.Write([]byte(l)) - h.Write([]byte("\n")) - } - return hex.EncodeToString(h.Sum(nil)) -} diff --git a/agent/internal/proxy/proxy.go b/agent/internal/proxy/proxy.go deleted file mode 100644 index 2d25cd0..0000000 --- a/agent/internal/proxy/proxy.go +++ /dev/null @@ -1,114 +0,0 @@ -// Package agentproxy relays a single TCP connection between a local service and -// the control plane, so a control plane that cannot route to this host's network -// can still open a console session. -// -// The dial host is hardcoded to loopback. The control plane supplies only a -// port, and nothing in this package can be made to dial anywhere else. -package agentproxy - -import ( - "errors" - "fmt" - "io" - "net" - "strconv" - "time" - - "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb" -) - -const ( - loopbackHost = "127.0.0.1" - chunkSize = 32 * 1024 - dialTimeout = 10 * time.Second -) - -// Stream is the agent's half of a ProxyStream. -type Stream interface { - Send(*pb.ProxyClientMsg) error - Recv() (*pb.ProxyServerMsg, error) - CloseSend() error -} - -// Open dials the local port, announces itself on the stream, and relays until -// either side ends. A refused dial is reported as an explicit close so the -// operator sees a reason rather than a hang. -func Open(stream Stream, serverID, agentToken, proxyID string, port uint32) error { - conn, dialErr := net.DialTimeout("tcp", - net.JoinHostPort(loopbackHost, strconv.Itoa(int(port))), dialTimeout) - - if err := stream.Send(&pb.ProxyClientMsg{Open: &pb.ProxyOpen{ - ServerId: serverID, - AgentToken: agentToken, - ProxyId: proxyID, - }}); err != nil { - if conn != nil { - _ = conn.Close() - } - return fmt.Errorf("send open: %w", err) - } - - if dialErr != nil { - _ = stream.Send(&pb.ProxyClientMsg{Close: &pb.ProxyClose{ - Reason: "dial_refused: " + dialErr.Error(), - }}) - _ = stream.CloseSend() - return fmt.Errorf("dial 127.0.0.1:%d: %w", port, dialErr) - } - defer conn.Close() - - return relay(conn, stream) -} - -func relay(conn net.Conn, stream Stream) error { - errCh := make(chan error, 2) - - // local service -> control plane - go func() { - buf := make([]byte, chunkSize) - for { - n, err := conn.Read(buf) - if n > 0 { - chunk := make([]byte, n) - copy(chunk, buf[:n]) - if sendErr := stream.Send(&pb.ProxyClientMsg{Data: chunk}); sendErr != nil { - errCh <- sendErr - return - } - } - if err != nil { - errCh <- err - return - } - } - }() - - // control plane -> local service - go func() { - for { - msg, err := stream.Recv() - if err != nil { - errCh <- err - return - } - if msg.Close != nil { - errCh <- fmt.Errorf("server closed relay: %s", msg.Close.Reason) - return - } - if len(msg.Data) > 0 { - if _, err := conn.Write(msg.Data); err != nil { - errCh <- err - return - } - } - } - }() - - err := <-errCh - _ = conn.Close() - _ = stream.CloseSend() - if errors.Is(err, io.EOF) { - return nil - } - return err -} diff --git a/agent/internal/sync/packages.go b/agent/internal/sync/packages.go deleted file mode 100644 index 4d3af8c..0000000 --- a/agent/internal/sync/packages.go +++ /dev/null @@ -1,125 +0,0 @@ -package agentsync - -import ( - "context" - "log" - "runtime" - "sync" - "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-shared/grpc/pb" -) - -// collectPackagesFlag is written by the 30s key poll and read by the hourly -// package loop — two different goroutines, hence the atomic. -// -// It defaults to false, so an agent that has not yet completed a poll, or is -// talking to a server too old to send the field, collects nothing. Off is the -// safe default: collecting without a licence costs the customer storage they -// are not paying for. -var collectPackagesFlag atomic.Bool - -// firstPoll closes once a SyncKeys response has set the flag above. -// -// Without it the boot-time package report loses a race it can only lose: the -// hourly loop starts before the first poll, reads a flag that is still false by -// construction, and skips — so a freshly installed agent reports no packages for -// an hour and the server shows nothing to scan. -// How long the boot package report waits for that first poll. Two poll -// intervals plus slack: long enough to cover one failed attempt, short enough -// that a dead control plane does not hold the OS-update report hostage. -const firstPollWait = 90 * time.Second - -var ( - firstPoll = make(chan struct{}) - firstPollOnce sync.Once -) - -func markFirstPoll() { firstPollOnce.Do(func() { close(firstPoll) }) } - -// waitFirstPoll blocks until the flag is known, or gives up. The wait is -// bounded because this loop also reports OS updates, which do not depend on the -// flag at all — a control plane that cannot be polled must not silence those too. -func waitFirstPoll(ctx context.Context, limit time.Duration) { - t := time.NewTimer(limit) - defer t.Stop() - select { - case <-firstPoll: - case <-t.C: - log.Printf("package collection: no SyncKeys response within %s, collecting nothing this round", limit) - case <-ctx.Done(): - } -} - -func collectPackagesEnabled() bool { return collectPackagesFlag.Load() } - -// reportPackages offers a hash of the installed package set and sends the full -// list only if the server does not already hold it. -// -// It runs on the same hourly cadence as the update check because a package set -// changes on roughly the same schedule, and reusing that loop means one timer -// rather than two. -func reportPackages(client *grpcclient.Client, cfg *config.Config) { - if runtime.GOOS != "linux" { - return - } - if !collectPackagesEnabled() { - return - } - - osrel, pkgs, err := packages.Collect() - if err != nil { - log.Printf("package collection error: %v", err) - return - } - - pbOS := pb.OSRelease{ - Family: osrel.Family, - VersionId: osrel.VersionID, - Arch: osrel.Arch, - } - hash := packages.Hash(pkgs) - - // The offer: hash only, no body. On an unchanged host this is the whole - // exchange, which is the point of the handshake. - needFull, err := client.ReportPackages(&pb.ReportPackagesRequest{ - ServerId: cfg.ServerID, - AgentToken: cfg.AgentToken, - Hash: hash, - Os: pbOS, - }) - if err != nil { - log.Printf("ReportPackages offer error: %v", err) - return - } - if !needFull { - return - } - - pbPkgs := make([]pb.InstalledPackage, len(pkgs)) - for i, p := range pkgs { - pbPkgs[i] = pb.InstalledPackage{ - Name: p.Name, - Version: p.Version, - Epoch: int32(p.Epoch), - Arch: p.Arch, - SourceName: p.SourceName, - } - } - - if _, err := client.ReportPackages(&pb.ReportPackagesRequest{ - ServerId: cfg.ServerID, - AgentToken: cfg.AgentToken, - Hash: hash, - Os: pbOS, - Packages: pbPkgs, - }); err != nil { - log.Printf("ReportPackages full error: %v", err) - return - } - log.Printf("reported %d installed packages", len(pkgs)) -} diff --git a/agent/internal/sync/sync.go b/agent/internal/sync/sync.go deleted file mode 100644 index cac3c9f..0000000 --- a/agent/internal/sync/sync.go +++ /dev/null @@ -1,808 +0,0 @@ -package agentsync - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "io" - "log" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "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-shared/grpc/pb" -) - -func Run(ctx context.Context, cfg *config.Config, version string) error { - client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) - if err != nil { - return fmt.Errorf("dial grpc: %w", err) - } - defer client.Close() - - if cfg.PreRegToken != "" { - log.Println("registering with server...") - hostname, _ := os.Hostname() - ipAddress := localIP() - osInfo := fmt.Sprintf("%s %s", runtime.GOOS, runtime.GOARCH) - - agentToken, err := client.Register(cfg.ServerID, cfg.PreRegToken, hostname, ipAddress, osInfo) - if err != nil { - return fmt.Errorf("registration failed: %w", err) - } - - cfg.AgentToken = agentToken - cfg.PreRegToken = "" - if err := config.Save(cfg); err != nil { - return fmt.Errorf("save config: %w", err) - } - log.Println("registration successful") - - client.Close() - client, err = grpcclient.New(cfg.ServerURL, cfg.TLS) - if err != nil { - return fmt.Errorf("reconnect: %w", err) - } - } - - if cfg.AgentToken == "" { - return fmt.Errorf("no agent token available registration required") - } - - go runCommandStream(ctx, cfg) - - go runUpdateCheck(ctx, cfg) - - go runInventory(ctx, cfg) - - go runWorkloads(ctx, cfg) - - go monitors.Run(ctx, cfg) - - ticker := time.NewTicker(cfg.PollInterval) - defer ticker.Stop() - - if err := poll(client, cfg, version); err != nil { - log.Printf("poll error: %v", err) - } - - for { - select { - case <-ctx.Done(): - return nil - case <-ticker.C: - if err := poll(client, cfg, version); err != nil { - log.Printf("poll error: %v", err) - } - } - } -} - -func poll(client *grpcclient.Client, cfg *config.Config, version string) error { - resp, err := client.SyncKeys(cfg.ServerID, cfg.AgentToken, version) - if err != nil { - return fmt.Errorf("SyncKeys: %w", err) - } - - // Stored atomically: the hourly package loop reads this from another - // goroutine. Absent on the wire decodes as false, so an older server leaves - // collection off rather than on. - collectPackagesFlag.Store(resp.CollectPackages) - markFirstPoll() - - desired := resp.PublicKeys - - if runtime.GOOS != "linux" { - return nil - } - - current, err := keys.ReadAuthorizedKeys() - if err != nil { - return fmt.Errorf("read authorized_keys: %w", err) - } - - if !keys.StateChanged(current, desired) { - log.Println("authorized_keys unchanged, skipping write") - return nil - } - - if err := keys.WriteAuthorizedKeys(desired); err != nil { - return fmt.Errorf("write authorized_keys: %w", err) - } - log.Printf("authorized_keys updated (%d keys)", len(desired)) - return nil -} - -// How long a command stream must survive before it counts as having worked. -// Past this, the next drop is treated as a fresh incident rather than as the -// continuation of a run of failures. -const streamHealthyAfter = time.Minute - -// Stream staleness. The server beats every 20s, so 70s tolerates three missed -// beats before the stream is written off — high enough that a slow network or a -// briefly busy server does not cost a reconnect, low enough that an agent is -// not uncommandable for minutes after a control-plane restart. -const ( - streamStaleAfter = 70 * time.Second - streamStaleCheck = 10 * time.Second - - // How often a healthy stream reports itself. Also the interval at which an - // agent talking to a control plane too old to send heartbeats says so — - // that agent is running without a watchdog, and the journal should not be - // silent about it. - pingSummaryInterval = 5 * time.Minute -) - -func runCommandStream(ctx context.Context, cfg *config.Config) { - backoff := time.Second - - // Two minutes was the old ceiling, and it was reached far too easily. The - // command stream is what makes this agent controllable at all: while it is - // down, workflows and console sessions fail as "agent offline" even though - // SyncKeys keeps polling happily and the fleet list still shows the server - // active. A shorter ceiling costs a few reconnect attempts; the old one cost - // two minutes of an agent that looks fine and answers nothing. - const maxBackoff = 30 * time.Second - - for { - select { - case <-ctx.Done(): - return - default: - } - - started := time.Now() - err := connectAndHandleStream(ctx, cfg) - if ctx.Err() != nil { - return - } - - // A stream that stayed up is evidence the control plane is reachable, - // whatever ended it. Without this the backoff only ever climbed: - // connectAndHandleStream returns an error on *every* stream end, - // including a healthy one dropped by a routine deploy, so an agent - // pinned itself at the ceiling after a handful of ordinary restarts and - // stayed there for the rest of its life. - if time.Since(started) >= streamHealthyAfter { - backoff = time.Second - } - - // The uptime is in the line because it is what distinguishes a stream - // that never worked from one that ran for hours and was dropped by a - // deploy — and it is the same measure that decides whether the backoff - // resets, so a reader can see why the delay is what it is. - up := time.Since(started).Truncate(time.Second) - if err != nil { - log.Printf("command stream error after %s: %v, reconnecting in %s", up, err, backoff) - } else { - log.Printf("command stream closed after %s, reconnecting in %s", up, backoff) - } - - select { - case <-ctx.Done(): - return - case <-time.After(backoff): - } - - if backoff < maxBackoff { - backoff *= 2 - if backoff > maxBackoff { - backoff = maxBackoff - } - } - } -} - -func connectAndHandleStream(ctx context.Context, cfg *config.Config) error { - client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) - if err != nil { - return fmt.Errorf("dial: %w", err) - } - defer client.Close() - - // Cancelling this context is what unblocks Recv when the stream has gone - // quiet. Without it the watchdog below would have no way to interrupt a - // read that is never going to return. - streamCtx, abandon := context.WithCancel(ctx) - defer abandon() - - stream, err := client.CommandStream(streamCtx) - if err != nil { - return fmt.Errorf("open stream: %w", err) - } - - if err := stream.Send(&pb.AgentMessage{ - ServerId: cfg.ServerID, - AgentToken: cfg.AgentToken, - Ready: &pb.AgentReady{}, - }); err != nil { - return fmt.Errorf("send auth: %w", err) - } - - log.Printf("command stream connected to %s", cfg.ServerURL) - - var sendMu sync.Mutex - send := func(msg *pb.AgentMessage) error { - sendMu.Lock() - defer sendMu.Unlock() - return stream.Send(msg) - } - - // Stream liveness, tracked here rather than left to gRPC keepalive. - // - // Keepalive operates on the transport, and behind an L7 proxy the transport - // ends at the proxy: it answers pings whether or not the server behind it - // is still running. A control-plane pod that dies therefore leaves this - // agent blocked in Recv on a stream that will never deliver another message - // and never error, while the control plane dispatches commands into it and - // the operator watches nothing happen. - // - // The watchdog only arms once a ping has actually been seen. A server too - // old to send them must not be treated as dead — that would put the agent - // in a reconnect loop against a control plane that is working perfectly. - var ( - lastMu sync.Mutex - lastRecv = time.Now() - pinged bool - beats int - ) - markRecv := func(isPing bool) { - lastMu.Lock() - lastRecv = time.Now() - if isPing { - beats++ - // Logged once per stream, because it is the moment the agent starts - // holding the control plane to account: before this the watchdog is - // disarmed and a dead stream would go unnoticed indefinitely. - if !pinged { - pinged = true - log.Printf("command stream heartbeat detected, watchdog armed (%s threshold)", streamStaleAfter) - } - } - lastMu.Unlock() - } - - go func() { - t := time.NewTicker(streamStaleCheck) - defer t.Stop() - - // Reported periodically rather than per beat: at one every 20s the - // journal would be nothing else. The count is what makes a partial - // failure visible — beats arriving but fewer than expected is a - // different problem from beats stopping altogether. - summary := time.NewTicker(pingSummaryInterval) - defer summary.Stop() - - for { - select { - case <-streamCtx.Done(): - return - case <-summary.C: - lastMu.Lock() - n, armed := beats, pinged - beats = 0 - lastMu.Unlock() - if armed { - log.Printf("command stream healthy, %d heartbeats in the last %s", n, pingSummaryInterval) - } else { - log.Printf("command stream up but sending no heartbeats; "+ - "control plane predates them, watchdog stays disarmed (last message %s ago)", - time.Since(lastRecv).Truncate(time.Second)) - } - case <-t.C: - lastMu.Lock() - idle, armed := time.Since(lastRecv), pinged - lastMu.Unlock() - if armed && idle > streamStaleAfter { - log.Printf("command stream silent for %s (threshold %s), assuming it is dead and reconnecting", - idle.Truncate(time.Second), streamStaleAfter) - abandon() - return - } - } - } - }() - - for { - cmd, err := stream.Recv() - if err != nil { - return fmt.Errorf("recv: %w", err) - } - markRecv(cmd.Ping != nil) - - // Pings carry nothing and are not acknowledged; being received is their - // whole purpose. - if cmd.Ping != nil { - continue - } - - if cmd.GenerateKey != nil { - go handleGenerateKey(cfg, cmd) - } - if cmd.DeleteKey != nil { - go handleDeleteKey(cmd) - } - if cmd.UpdateAgent != nil { - go handleUpdateAgent(cmd) - } - if cmd.ApplyUpdates != nil { - go handleApplyUpdates(cfg, cmd) - } - if cmd.CleanupWorkspace != nil { - go handleCleanupWorkspace(cmd) - } - if cmd.OpenProxy != nil { - go handleOpenProxy(ctx, cfg, cmd.OpenProxy) - } - if cmd.RefreshWorkloads != nil { - go handleRefreshWorkloads(cfg) - } - if cmd.ControlWorkload != nil { - go handleControlWorkload(send, cfg, cmd.CommandId, cmd.ControlWorkload) - } - if cmd.WorkloadLogs != nil { - go handleWorkloadLogs(send, cfg, cmd.CommandId, cmd.WorkloadLogs) - } - if cmd.RunStep != nil { - go func(rc *pb.RunStepCmd, cid string) { - emit := func(seq uint64, data []byte) { - _ = send(&pb.AgentMessage{ - ServerId: cfg.ServerID, - AgentToken: cfg.AgentToken, - StepOutput: &pb.StepOutputChunk{CommandId: cid, Seq: seq, Data: data}, - }) - } - res := agentexec.RunStep(rc, emit) - res.CommandId = cid - - _ = send(&pb.AgentMessage{ - ServerId: cfg.ServerID, - AgentToken: cfg.AgentToken, - StepOutput: &pb.StepOutputChunk{CommandId: cid, Eof: true}, - }) - _ = send(&pb.AgentMessage{ - ServerId: cfg.ServerID, - AgentToken: cfg.AgentToken, - StepResult: res, - }) - }(cmd.RunStep, cmd.CommandId) - continue - } - } -} - -func runUpdateCheck(ctx context.Context, cfg *config.Config) { - const interval = time.Hour - - doCheck := func() { - pkgs, err := updates.CheckAvailable() - if err != nil { - log.Printf("update check error: %v", err) - return - } - pbUpdates := make([]pb.PackageUpdate, len(pkgs)) - for i, p := range pkgs { - pbUpdates[i] = pb.PackageUpdate{ - Name: p.Name, - CurrentVersion: p.CurrentVersion, - NewVersion: p.NewVersion, - } - } - client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) - if err != nil { - log.Printf("update report dial error: %v", err) - return - } - defer client.Close() - if err := client.ReportUpdates(cfg.ServerID, cfg.AgentToken, pbUpdates); err != nil { - log.Printf("ReportUpdates error: %v", err) - return - } - log.Printf("reported %d available OS updates", len(pkgs)) - - // Same hourly cadence, same connection. A package set changes on - // roughly the schedule available updates do, so this needs no timer of - // its own. - reportPackages(client, cfg) - } - - // The boot round only: after this the flag has long been set, and every - // later tick is an hour past a poll that runs every 30s. - waitFirstPoll(ctx, firstPollWait) - - doCheck() - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - doCheck() - } - } -} - -func runInventory(ctx context.Context, cfg *config.Config) { - client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) - if err != nil { - log.Printf("inventory dial error: %v", err) - return - } - defer client.Close() - - report := func(static bool) { - r := inventory.Collect(static) - r.ServerId = cfg.ServerID - r.AgentToken = cfg.AgentToken - // Static snapshots only — every 15 minutes, not every 30 seconds. On - // Windows this spawns a PowerShell process, which is not something to - // do twice a minute forever, and a host rebooted by hand clearing the - // flag within a quarter of an hour is soon enough. - // - // Computed here rather than inside inventory.Collect so the inventory - // package gains no dependency on updates. - if static { - r.RebootRequired = updates.RebootRequired() - } - if err := client.ReportInventory(r); err != nil { - log.Printf("report inventory: %v", err) - } - } - - report(true) - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - tick := 0 - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - tick++ - report(tick%30 == 0) - } - } -} - -func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) { - log.Printf("applying OS updates (cmd=%s)…", cmd.CommandId) - if err := updates.ApplyAll(); err != nil { - log.Printf("OS upgrade failed (cmd=%s): %v", cmd.CommandId, err) - return - } - log.Printf("OS updates applied successfully (cmd=%s)", cmd.CommandId) - - client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) - if err != nil { - return - } - defer client.Close() - _ = client.ReportUpdates(cfg.ServerID, cfg.AgentToken, nil) -} - -func handleCleanupWorkspace(cmd *pb.ServerCommand) { - id := cmd.CleanupWorkspace.WorkspaceId - dir := agentexec.WorkspacePath(id) - if err := os.RemoveAll(dir); err != nil { - log.Printf("cleanup workspace %s failed (cmd=%s): %v", dir, cmd.CommandId, err) - return - } - log.Printf("removed run workspace %s (cmd=%s)", dir, cmd.CommandId) -} - -// handleOpenProxy relays one console connection. It uses its own gRPC -// connection so console traffic never shares a stream with commands, key sync -// or workflow output. -func handleOpenProxy(ctx context.Context, cfg *config.Config, cmd *pb.OpenProxyCmd) { - client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) - if err != nil { - log.Printf("proxy %s: dial control plane: %v", cmd.ProxyId, err) - return - } - defer client.Close() - - stream, err := client.ProxyStream(ctx) - if err != nil { - log.Printf("proxy %s: open stream: %v", cmd.ProxyId, err) - return - } - - log.Printf("proxy %s: relaying 127.0.0.1:%d", cmd.ProxyId, cmd.Port) - if err := agentproxy.Open(stream, cfg.ServerID, cfg.AgentToken, cmd.ProxyId, cmd.Port); err != nil { - log.Printf("proxy %s: %v", cmd.ProxyId, err) - } -} - -func handleDeleteKey(cmd *pb.ServerCommand) { - label := cmd.DeleteKey.Label - keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_")) - - if err := keys.RemoveSSHIdentity(keyPath); err != nil { - log.Printf("remove ssh identity failed (cmd=%s): %v", cmd.CommandId, err) - } - - for _, path := range []string{keyPath, keyPath + ".pub"} { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - log.Printf("delete key file %s (cmd=%s): %v", path, cmd.CommandId, err) - } - } - log.Printf("deleted local key files for %q (cmd=%s)", label, cmd.CommandId) -} - -func handleUpdateAgent(cmd *pb.ServerCommand) { - if runtime.GOOS == "windows" { - handleUpdateAgentWindows(cmd) - return - } - - 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) - - log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId) - - tmpBin := "/tmp/vantage-agent-update" - if err := downloadFile(binaryURL, tmpBin); err != nil { - log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err) - return - } - - checksumData, err := httpGetBytes(checksumURL) - if err != nil { - log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err) - return - } - if err := verifyChecksum(tmpBin, fmt.Sprintf("vantage-agent-linux-%s", arch), checksumData); err != nil { - log.Printf("update checksum mismatch (cmd=%s): %v", cmd.CommandId, err) - os.Remove(tmpBin) - return - } - - if err := os.Chmod(tmpBin, 0755); err != nil { - log.Printf("update chmod failed (cmd=%s): %v", cmd.CommandId, err) - return - } - if err := os.Rename(tmpBin, "/usr/local/bin/vantage-agent"); err != nil { - log.Printf("update replace binary failed (cmd=%s): %v", cmd.CommandId, err) - return - } - - log.Printf("agent binary replaced, restarting service (cmd=%s)", cmd.CommandId) - exec.Command("systemctl", "restart", "vantage-agent").Run() -} - -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) - - log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId) - - msiPath := filepath.Join(os.TempDir(), "vantage-agent-update.msi") - if err := downloadFile(msiURL, msiPath); err != nil { - log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err) - return - } - - checksumData, err := httpGetBytes(checksumURL) - if err != nil { - log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err) - return - } - if err := verifyChecksum(msiPath, "vantage-agent.msi", checksumData); err != nil { - log.Printf("update checksum mismatch (cmd=%s): %v", cmd.CommandId, err) - os.Remove(msiPath) - return - } - - logPath := filepath.Join(os.TempDir(), "vantage-agent-msi.log") - - // The MSI stops the vantage-agent service as part of the upgrade. Anything - // descended from this process is killed with it, so msiexec must not be a - // child: run it from a scheduled task, which is parented to the Task - // Scheduler service instead. - if err := launchDetachedUpdate(msiPath, logPath, cmd.CommandId); err != nil { - log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err) - return - } - log.Printf("scheduled msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId) -} - -const updateTaskName = "VantageAgentUpdate" - -func launchDetachedUpdate(msiPath, logPath, commandID string) error { - scriptPath := filepath.Join(os.TempDir(), "vantage-agent-update.cmd") - script := fmt.Sprintf("@echo off\r\n"+ - "timeout /t 5 /nobreak >nul\r\n"+ - "msiexec /i \"%s\" /qn /norestart /l*v \"%s\"\r\n"+ - "schtasks /delete /tn %s /f >nul 2>&1\r\n"+ - "del /f /q \"%s\" >nul 2>&1\r\n"+ - "(goto) 2>nul & del /f /q \"%%~f0\"\r\n", - msiPath, logPath, updateTaskName, msiPath) - if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil { - return fmt.Errorf("write update script: %w", err) - } - - // Stale task from a previous attempt would make /create fail even with /f - // if it is still running, so tear it down first and ignore the result. - exec.Command("schtasks", "/end", "/tn", updateTaskName).Run() - exec.Command("schtasks", "/delete", "/tn", updateTaskName, "/f").Run() - - create := exec.Command("schtasks", "/create", - "/tn", updateTaskName, - "/tr", `"`+scriptPath+`"`, - "/sc", "once", - // Already in the past: the task never fires on its own, only via /run. - "/st", "00:00", - "/ru", "SYSTEM", - "/rl", "HIGHEST", - "/f") - if out, err := create.CombinedOutput(); err != nil { - return fmt.Errorf("schtasks create: %v: %s", err, strings.TrimSpace(string(out))) - } - - if out, err := exec.Command("schtasks", "/run", "/tn", updateTaskName).CombinedOutput(); err != nil { - return fmt.Errorf("schtasks run: %v: %s", err, strings.TrimSpace(string(out))) - } - return nil -} - -func downloadFile(url, dest string) error { - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) - } - f, err := os.Create(dest) - if err != nil { - return err - } - defer f.Close() - _, err = io.Copy(f, resp.Body) - return err -} - -func httpGetBytes(url string) ([]byte, error) { - resp, err := http.Get(url) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) - } - return io.ReadAll(resp.Body) -} - -func verifyChecksum(filePath, filename string, checksumData []byte) error { - f, err := os.Open(filePath) - if err != nil { - return err - } - defer f.Close() - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return err - } - actual := hex.EncodeToString(h.Sum(nil)) - - for _, line := range strings.Split(string(checksumData), "\n") { - fields := strings.Fields(line) - if len(fields) == 2 && fields[1] == filename { - if fields[0] != actual { - return fmt.Errorf("expected %s got %s", fields[0], actual) - } - return nil - } - } - return fmt.Errorf("no checksum entry found for %s", filename) -} - -func handleGenerateKey(cfg *config.Config, cmd *pb.ServerCommand) { - g := cmd.GenerateKey - label := g.Label - keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_")) - - opts := keys.KeyGenOptions{ - KeyType: g.KeyType, - KeySize: g.KeySize, - Passphrase: g.Passphrase, - Comment: g.Comment, - } - pubKey, err := keys.GenerateKeyPair(keyPath, opts) - if err != nil { - log.Printf("key generation failed (cmd=%s): %v", cmd.CommandId, err) - return - } - - privKeyData, err := os.ReadFile(keyPath) - if err != nil { - log.Printf("read private key failed (cmd=%s): %v", cmd.CommandId, err) - return - } - - client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) - if err != nil { - log.Printf("dial for key upload failed (cmd=%s): %v", cmd.CommandId, err) - return - } - defer client.Close() - - keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, string(privKeyData), label) - if err != nil { - log.Printf("key upload failed (cmd=%s): %v", cmd.CommandId, err) - return - } - - if err := keys.AddSSHIdentity(keyPath); err != nil { - log.Printf("add ssh identity failed (cmd=%s): %v", cmd.CommandId, err) - } - log.Printf("generated and uploaded key %q (key_id=%s, cmd=%s)", label, keyID, cmd.CommandId) -} - -func localIP() string { - addrs, err := net.InterfaceAddrs() - if err != nil { - return "" - } - for _, addr := range addrs { - if ipNet, ok := addr.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { - if ipNet.IP.To4() != nil { - return ipNet.IP.String() - } - } - } - return "" -} - -func GenerateAndUpload(cfg *config.Config, label string) error { - client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) - if err != nil { - return err - } - defer client.Close() - - keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_")) - pubKey, err := keys.GenerateKeyPair(keyPath, keys.KeyGenOptions{Comment: label}) - if err != nil { - return err - } - - privKeyData, err := os.ReadFile(keyPath) - if err != nil { - return fmt.Errorf("read private key: %w", err) - } - - keyID, err := client.UploadGeneratedKey(cfg.ServerID, cfg.AgentToken, pubKey, string(privKeyData), label) - if err != nil { - return err - } - - if err := keys.AddSSHIdentity(keyPath); err != nil { - log.Printf("add ssh identity: %v", err) - } - log.Printf("uploaded generated key %s (key_id=%s)", label, keyID) - return nil -} diff --git a/agent/internal/sync/workloads.go b/agent/internal/sync/workloads.go deleted file mode 100644 index 5479a6d..0000000 --- a/agent/internal/sync/workloads.go +++ /dev/null @@ -1,152 +0,0 @@ -package agentsync - -import ( - "context" - "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-shared/grpc/pb" -) - -// workloadInterval is the report cadence. Sixty seconds is affordable because -// an unchanged list costs one small offer message, not the body. -const workloadInterval = 60 * time.Second - -// runWorkloads reports what this host runs, on its own ticker. -func runWorkloads(ctx context.Context, cfg *config.Config) { - reportWorkloads(cfg) - - ticker := time.NewTicker(workloadInterval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - reportWorkloads(cfg) - } - } -} - -// reportWorkloads offers a hash of the current workload set and sends the full -// list only if the server does not already hold it. -// -// This is the ONLY writer of the server_workloads collection. RefreshWorkloadsCmd -// calls straight into here rather than answering with data of its own. -func reportWorkloads(cfg *config.Config) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - res := workloads.Collect(ctx) - hash := workloads.Hash(res.Workloads) - - client, err := grpcclient.New(cfg.ServerURL, cfg.TLS) - if err != nil { - log.Printf("workload report dial error: %v", err) - return - } - defer client.Close() - - base := func() *pb.ReportWorkloadsRequest { - return &pb.ReportWorkloadsRequest{ - ServerId: cfg.ServerID, - AgentToken: cfg.AgentToken, - Hash: hash, - DockerOk: res.DockerOK, - DockerError: res.DockerError, - SystemdOk: res.SystemdOK, - SystemdError: res.SystemdError, - } - } - - // The offer: hash only, no body. On an unchanged host this is the whole - // exchange, which is the point of the handshake. - needFull, err := client.ReportWorkloads(base()) - if err != nil { - log.Printf("ReportWorkloads offer error: %v", err) - return - } - if !needFull { - return - } - - req := base() - req.Full = true - req.Workloads = make([]pb.Workload, len(res.Workloads)) - for i, w := range res.Workloads { - req.Workloads[i] = pb.Workload{ - Kind: w.Kind, - Id: w.ID, - Name: w.Name, - State: w.State, - Health: w.Health, - Image: w.Image, - Stack: w.Stack, - Ports: w.Ports, - Restarts: int32(w.Restarts), - Protected: w.Protected, - } - if !w.StartedAt.IsZero() { - req.Workloads[i].StartedAt = w.StartedAt.Format(time.RFC3339) - } - } - - if _, err := client.ReportWorkloads(req); err != nil { - log.Printf("ReportWorkloads error: %v", err) - return - } - log.Printf("reported %d workload(s)", len(res.Workloads)) -} - -// handleRefreshWorkloads makes the agent report immediately. It sends nothing -// back beyond the stream ack: the refresh is a nudge, not a channel, so there -// is one writer for the collection rather than two. -func handleRefreshWorkloads(cfg *config.Config) { - reportWorkloads(cfg) -} - -// handleControlWorkload starts, stops or restarts a workload and answers with -// the ordinary CommandResult. -// -// The agent's own protected check inside workloads.Control is the boundary; the -// Protected flag it reports is only there so the UI can grey the button. -func handleControlWorkload(send func(*pb.AgentMessage) error, cfg *config.Config, commandID string, cmd *pb.ControlWorkloadCmd) { - err := workloads.Control(context.Background(), cmd.Kind, cmd.Id, cmd.Action) - - res := &pb.CommandResult{CommandId: commandID, Success: err == nil} - if err != nil { - res.Message = err.Error() - log.Printf("workload %s %s failed (cmd=%s): %v", cmd.Action, cmd.Id, commandID, err) - } else { - res.Message = cmd.Action + " " + cmd.Id + " ok" - } - - _ = send(&pb.AgentMessage{ - ServerId: cfg.ServerID, - AgentToken: cfg.AgentToken, - Result: res, - }) - - // Report straight away on success so the UI's refetch shows the new state - // rather than the old one. - if err == nil { - reportWorkloads(cfg) - } -} - -func handleWorkloadLogs(send func(*pb.AgentMessage) error, cfg *config.Config, commandID string, cmd *pb.WorkloadLogsCmd) { - text, truncated, err := workloads.Logs(context.Background(), cmd.Kind, cmd.Id, int(cmd.Tail)) - res := &pb.WorkloadLogsResult{CommandId: commandID, Text: text, Truncated: truncated} - if err != nil { - res.Error = err.Error() - } - - _ = send(&pb.AgentMessage{ - ServerId: cfg.ServerID, - AgentToken: cfg.AgentToken, - WorkloadLogsResult: res, - }) -} diff --git a/agent/internal/updates/updates.go b/agent/internal/updates/updates.go deleted file mode 100644 index 293da52..0000000 --- a/agent/internal/updates/updates.go +++ /dev/null @@ -1,24 +0,0 @@ -package updates - -// PackageUpdate is one pending update. On Linux it is a package with a version -// on each side. On Windows CurrentVersion is empty and NewVersion carries the -// KB article ID: a Windows update is not a version bump of a named package, -// and inventing a current version would put a wrong string in front of an -// operator. -type PackageUpdate struct { - Name string - CurrentVersion string - NewVersion string -} - -// CheckAvailable lists pending OS updates. -func CheckAvailable() ([]PackageUpdate, error) { return checkAvailable() } - -// ApplyAll installs every pending update. It never reboots: a control plane -// silently restarting a production server is unrecoverable from the UI, so the -// reboot stays a decision a person or a workflow makes. RebootRequired reports -// when one is owed. -func ApplyAll() error { return applyAll() } - -// RebootRequired reports whether this host is waiting on a restart. -func RebootRequired() bool { return rebootRequired() } diff --git a/agent/internal/updates/updates_linux.go b/agent/internal/updates/updates_linux.go deleted file mode 100644 index 4c16402..0000000 --- a/agent/internal/updates/updates_linux.go +++ /dev/null @@ -1,252 +0,0 @@ -package updates - -import ( - "bufio" - "bytes" - "context" - "os" - "os/exec" - "strings" - "time" -) - -func detectPM() string { - for _, pm := range []string{"apt-get", "dnf", "yum", "pacman", "zypper", "apk"} { - if _, err := exec.LookPath(pm); err == nil { - if pm == "apt-get" { - return "apt" - } - return pm - } - } - return "" -} - - - -func checkAvailable() ([]PackageUpdate, error) { - switch detectPM() { - case "apt": - return checkApt() - case "dnf": - return checkDnfYum("dnf") - case "yum": - return checkDnfYum("yum") - case "pacman": - return checkPacman() - case "zypper": - return checkZypper() - case "apk": - return checkApk() - default: - return nil, nil - } -} - - -func applyAll() error { - switch detectPM() { - case "apt": - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil { - return err - } - return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run() - case "dnf": - return exec.Command("dnf", "upgrade", "-y").Run() - case "yum": - return exec.Command("yum", "upgrade", "-y").Run() - case "pacman": - return exec.Command("pacman", "-Syu", "--noconfirm").Run() - case "zypper": - return exec.Command("zypper", "update", "-y").Run() - case "apk": - return exec.Command("apk", "upgrade").Run() - default: - return nil - } -} - -// rebootRequired reads what the distributions themselves record. Debian and -// Ubuntu drop a file; the RPM family answers through needs-restarting, whose -// exit code is 1 when a reboot is owed and 0 when it is not. -func rebootRequired() bool { - if _, err := os.Stat("/var/run/reboot-required"); err == nil { - return true - } - if _, err := exec.LookPath("dnf"); err == nil { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - if err := exec.CommandContext(ctx, "dnf", "needs-restarting", "-r").Run(); err != nil { - if ee, ok := err.(*exec.ExitError); ok && ee.ExitCode() == 1 { - return true - } - } - } - return false -} - -func checkApt() ([]PackageUpdate, error) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - exec.CommandContext(ctx, "apt-get", "update", "-qq").Run() - - out, err := exec.Command("apt", "list", "--upgradable").Output() - if err != nil { - return nil, err - } - var updates []PackageUpdate - scanner := bufio.NewScanner(bytes.NewReader(out)) - for scanner.Scan() { - line := scanner.Text() - - if !strings.Contains(line, "[upgradable from:") { - continue - } - parts := strings.Fields(line) - if len(parts) < 2 { - continue - } - name := strings.SplitN(parts[0], "/", 2)[0] - newVer := parts[1] - oldVer := "" - if idx := strings.Index(line, "upgradable from: "); idx != -1 { - rest := line[idx+len("upgradable from: "):] - oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]") - } - updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer}) - } - return updates, nil -} - -func checkDnfYum(pm string) ([]PackageUpdate, error) { - cmd := exec.Command(pm, "check-update") - out, err := cmd.Output() - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 { - err = nil - } - if err != nil { - return nil, err - } - var updates []PackageUpdate - pastHeader := false - scanner := bufio.NewScanner(bytes.NewReader(out)) - for scanner.Scan() { - line := scanner.Text() - if !pastHeader { - if strings.TrimSpace(line) == "" { - pastHeader = true - } - continue - } - parts := strings.Fields(line) - if len(parts) < 2 { - continue - } - - name := strings.SplitN(parts[0], ".", 2)[0] - updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]}) - } - return updates, nil -} - -func checkPacman() ([]PackageUpdate, error) { - out, _ := exec.Command("pacman", "-Qu").Output() - var updates []PackageUpdate - scanner := bufio.NewScanner(bytes.NewReader(out)) - for scanner.Scan() { - parts := strings.Fields(scanner.Text()) - - if len(parts) < 4 { - continue - } - updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]}) - } - return updates, nil -} - -func checkZypper() ([]PackageUpdate, error) { - out, err := exec.Command("zypper", "list-updates").Output() - if err != nil { - return nil, err - } - var updates []PackageUpdate - scanner := bufio.NewScanner(bytes.NewReader(out)) - for scanner.Scan() { - line := scanner.Text() - - if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") { - continue - } - parts := strings.Split(line, "|") - if len(parts) < 5 { - continue - } - updates = append(updates, PackageUpdate{ - Name: strings.TrimSpace(parts[2]), - CurrentVersion: strings.TrimSpace(parts[3]), - NewVersion: strings.TrimSpace(parts[4]), - }) - } - return updates, nil -} - -func checkApk() ([]PackageUpdate, error) { - out, err := exec.Command("apk", "list", "--upgradable").Output() - if err != nil { - return nil, err - } - var updates []PackageUpdate - scanner := bufio.NewScanner(bytes.NewReader(out)) - for scanner.Scan() { - line := scanner.Text() - if !strings.Contains(line, "[upgradable") { - continue - } - parts := strings.Fields(line) - if len(parts) < 1 { - continue - } - pkgVer := parts[0] - name := apkName(pkgVer) - newVer := apkVersion(pkgVer) - oldVer := "" - if idx := strings.Index(line, "upgradable from:"); idx != -1 { - rest := strings.TrimSpace(line[idx+len("upgradable from:"):]) - rest = strings.TrimSuffix(rest, "]") - oldVer = apkVersion(strings.TrimSpace(rest)) - } - updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer}) - } - return updates, nil -} - -func apkName(pkgVer string) string { - parts := strings.Split(pkgVer, "-") - var name []string - for _, p := range parts { - if len(p) > 0 && p[0] >= '0' && p[0] <= '9' { - break - } - name = append(name, p) - } - return strings.Join(name, "-") -} - -func apkVersion(pkgVer string) string { - parts := strings.Split(pkgVer, "-") - var ver []string - inVer := false - for _, p := range parts { - if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' { - inVer = true - } - if inVer { - ver = append(ver, p) - } - } - return strings.Join(ver, "-") -} diff --git a/agent/internal/updates/updates_other.go b/agent/internal/updates/updates_other.go deleted file mode 100644 index f97615b..0000000 --- a/agent/internal/updates/updates_other.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !linux && !windows - -// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so -// without it this file compiles on Linux too and collides with updates_linux.go. -package updates - -func checkAvailable() ([]PackageUpdate, error) { return nil, nil } -func applyAll() error { return nil } -func rebootRequired() bool { return false } diff --git a/agent/internal/updates/updates_windows.go b/agent/internal/updates/updates_windows.go deleted file mode 100644 index 1e73729..0000000 --- a/agent/internal/updates/updates_windows.go +++ /dev/null @@ -1,117 +0,0 @@ -package updates - -import ( - "context" - "fmt" - "strings" - "time" - - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec" -) - -const ( - // The first search after a boot contacts Microsoft Update (or WSUS) and is - // routinely slow. Ten minutes is not generous, it is realistic. - searchTimeout = 10 * time.Minute - - // A patch-Tuesday cumulative genuinely takes this long to download and - // install on a modest server. - applyTimeout = 60 * time.Minute - - rebootTimeout = 2 * time.Minute -) - -// The Windows Update COM API is used rather than the PSWindowsUpdate module: it -// is present on every supported Windows, needs no PowerShell Gallery install, -// and works unchanged against a WSUS server on an air-gapped fleet. The agent -// runs as LocalSystem, which holds the rights it requires. -const searchScript = ` -$ErrorActionPreference = 'Stop' -$searcher = (New-Object -ComObject Microsoft.Update.Session).CreateUpdateSearcher() -$result = $searcher.Search("IsInstalled=0 and Type='Software' and IsHidden=0") -$rows = @() -foreach ($u in $result.Updates) { - $ids = @($u.KBArticleIDs) - $kb = '' - if ($ids.Count -gt 0) { $kb = [string]$ids[0] } - $rows += [pscustomobject]@{ title = [string]$u.Title; kb = $kb } -} -ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress -` - -const applyScript = ` -$ErrorActionPreference = 'Stop' -$session = New-Object -ComObject Microsoft.Update.Session -$result = $session.CreateUpdateSearcher().Search("IsInstalled=0 and Type='Software' and IsHidden=0") - -$batch = New-Object -ComObject Microsoft.Update.UpdateColl -foreach ($u in $result.Updates) { - if ($u.InstallationBehavior.CanRequestUserInput) { continue } - if (-not $u.EulaAccepted) { - try { $u.AcceptEula() } catch { continue } - } - $null = $batch.Add($u) -} - -if ($batch.Count -eq 0) { Write-Output 'nothing-to-install'; exit 0 } - -$downloader = $session.CreateUpdateDownloader() -$downloader.Updates = $batch -$null = $downloader.Download() - -$installer = $session.CreateUpdateInstaller() -$installer.Updates = $batch -$r = $installer.Install() - -Write-Output ('resultcode=' + $r.ResultCode) -# 2 = succeeded, 3 = succeeded with errors. Anything else failed, and this -# process must exit non-zero so the agent logs a failure rather than an ack. -if ($r.ResultCode -ne 2 -and $r.ResultCode -ne 3) { exit 1 } -exit 0 -` - -const rebootScript = ` -$ErrorActionPreference = 'SilentlyContinue' -$si = New-Object -ComObject Microsoft.Update.SystemInfo -if ($si.RebootRequired) { Write-Output 'true'; exit 0 } -$keys = @( - 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending', - 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired' -) -foreach ($k in $keys) { if (Test-Path $k) { Write-Output 'true'; exit 0 } } -$sm = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' -Name PendingFileRenameOperations -if ($sm -and $sm.PendingFileRenameOperations) { Write-Output 'true'; exit 0 } -Write-Output 'false' -` - -func checkAvailable() ([]PackageUpdate, error) { - ctx, cancel := context.WithTimeout(context.Background(), searchTimeout) - defer cancel() - - out, err := winexec.Run(ctx, searchScript) - if err != nil { - return nil, fmt.Errorf("windows update search: %w", err) - } - return parseUpdateSearch(out) -} - -func applyAll() error { - ctx, cancel := context.WithTimeout(context.Background(), applyTimeout) - defer cancel() - - if _, err := winexec.Run(ctx, applyScript); err != nil { - return fmt.Errorf("windows update install: %w", err) - } - return nil -} - -func rebootRequired() bool { - ctx, cancel := context.WithTimeout(context.Background(), rebootTimeout) - defer cancel() - - out, err := winexec.Run(ctx, rebootScript) - if err != nil { - return false - } - return strings.TrimSpace(out) == "true" -} diff --git a/agent/internal/updates/winparse.go b/agent/internal/updates/winparse.go deleted file mode 100644 index cd3f5f4..0000000 --- a/agent/internal/updates/winparse.go +++ /dev/null @@ -1,48 +0,0 @@ -package updates - -import ( - "encoding/json" - "strings" -) - -// winUpdate is one row of the Windows Update searcher's output, in the shape -// searchScript emits it. -type winUpdate struct { - Title string `json:"title"` - KB string `json:"kb"` -} - -// parseUpdateSearch reads the searcher's JSON. -// -// It carries no build tag on purpose: this is the half of the Windows update -// path that can be tested on a development machine, and the agent module has no -// Windows CI. -func parseUpdateSearch(jsonText string) ([]PackageUpdate, error) { - s := strings.TrimSpace(jsonText) - if s == "" || s == "null" { - return nil, nil - } - - var rows []winUpdate - if err := json.Unmarshal([]byte(s), &rows); err != nil { - // ConvertTo-Json renders a one-element array as a bare object. - var one winUpdate - if err2 := json.Unmarshal([]byte(s), &one); err2 != nil { - return nil, err - } - rows = []winUpdate{one} - } - - out := make([]PackageUpdate, 0, len(rows)) - for _, r := range rows { - u := PackageUpdate{Name: r.Title} - if kb := strings.TrimSpace(r.KB); kb != "" { - if !strings.HasPrefix(strings.ToUpper(kb), "KB") { - kb = "KB" + kb - } - u.NewVersion = kb - } - out = append(out, u) - } - return out, nil -} diff --git a/agent/internal/updates/winparse_test.go b/agent/internal/updates/winparse_test.go deleted file mode 100644 index d56aad9..0000000 --- a/agent/internal/updates/winparse_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package updates - -import "testing" - -func TestParseUpdateSearchArray(t *testing.T) { - in := `[{"title":"2026-08 Cumulative Update for Windows Server 2022","kb":"5034123"}, - {"title":"Windows Malicious Software Removal Tool","kb":"890830"}]` - - got, err := parseUpdateSearch(in) - if err != nil { - t.Fatalf("parseUpdateSearch: %v", err) - } - if len(got) != 2 { - t.Fatalf("got %d updates, want 2", len(got)) - } - if got[0].Name != "2026-08 Cumulative Update for Windows Server 2022" { - t.Errorf("Name = %q", got[0].Name) - } - if got[0].NewVersion != "KB5034123" { - t.Errorf("NewVersion = %q, want KB5034123", got[0].NewVersion) - } - if got[0].CurrentVersion != "" { - t.Errorf("CurrentVersion = %q, want empty", got[0].CurrentVersion) - } -} - -// PowerShell 5.1's ConvertTo-Json collapses a one-element array into a bare -// object. A host with exactly one pending update is common, and a parser that -// only accepts arrays reports it as zero. -func TestParseUpdateSearchSingleObject(t *testing.T) { - got, err := parseUpdateSearch(`{"title":"Security Intelligence Update","kb":"2267602"}`) - if err != nil { - t.Fatalf("parseUpdateSearch: %v", err) - } - if len(got) != 1 || got[0].NewVersion != "KB2267602" { - t.Fatalf("got %+v", got) - } -} - -func TestParseUpdateSearchNoKB(t *testing.T) { - got, err := parseUpdateSearch(`[{"title":"Driver update for Contoso NIC","kb":""}]`) - if err != nil { - t.Fatalf("parseUpdateSearch: %v", err) - } - if len(got) != 1 || got[0].NewVersion != "" { - t.Fatalf("got %+v, want one update with an empty NewVersion", got) - } -} - -// An empty result set is "nothing pending", not a parse failure. -func TestParseUpdateSearchEmpty(t *testing.T) { - for _, in := range []string{"", " \r\n", "[]", "null"} { - got, err := parseUpdateSearch(in) - if err != nil { - t.Fatalf("parseUpdateSearch(%q): %v", in, err) - } - if len(got) != 0 { - t.Fatalf("parseUpdateSearch(%q) = %+v, want none", in, got) - } - } -} - -// A KB already carrying its prefix must not become KBKB5034123. -func TestParseUpdateSearchPrefixedKB(t *testing.T) { - got, _ := parseUpdateSearch(`[{"title":"x","kb":"KB5034123"}]`) - if got[0].NewVersion != "KB5034123" { - t.Fatalf("NewVersion = %q", got[0].NewVersion) - } -} diff --git a/agent/internal/winexec/encode.go b/agent/internal/winexec/encode.go deleted file mode 100644 index 8ea7cc8..0000000 --- a/agent/internal/winexec/encode.go +++ /dev/null @@ -1,24 +0,0 @@ -// Package winexec runs PowerShell on Windows hosts. -// -// It exists because three subsystems — updates, workload collection and -// workload logs — all need the same invocation, and because getting a -// multi-line script past Go quoting, cmd.exe quoting and PowerShell's own -// parser is a problem worth solving once. -package winexec - -import ( - "encoding/base64" - "unicode/utf16" -) - -// EncodeCommand renders a script for powershell.exe -EncodedCommand: UTF-16LE, -// no byte-order mark, base64. This is deliberately free of build tags so it is -// tested on a Linux development machine like every other pure function here. -func EncodeCommand(script string) string { - units := utf16.Encode([]rune(script)) - b := make([]byte, 0, len(units)*2) - for _, u := range units { - b = append(b, byte(u), byte(u>>8)) - } - return base64.StdEncoding.EncodeToString(b) -} diff --git a/agent/internal/winexec/encode_test.go b/agent/internal/winexec/encode_test.go deleted file mode 100644 index e404e16..0000000 --- a/agent/internal/winexec/encode_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package winexec - -import "testing" - -func TestEncodeCommand(t *testing.T) { - // "hi" as UTF-16LE is 68 00 69 00, which base64-encodes to aABpAA==. - if got := EncodeCommand("hi"); got != "aABpAA==" { - t.Fatalf("EncodeCommand(hi) = %q, want aABpAA==", got) - } -} - -func TestEncodeCommandMultiline(t *testing.T) { - // Only that it round-trips through the same encoding PowerShell expects: - // every ASCII byte followed by a zero byte, no BOM. - got := EncodeCommand("a\nb") - want := "YQAKAGIA" - if got != want { - t.Fatalf("EncodeCommand = %q, want %q", got, want) - } -} diff --git a/agent/internal/winexec/run_windows.go b/agent/internal/winexec/run_windows.go deleted file mode 100644 index c91b05a..0000000 --- a/agent/internal/winexec/run_windows.go +++ /dev/null @@ -1,35 +0,0 @@ -package winexec - -import ( - "context" - "fmt" - "os/exec" - "strings" -) - -// Run executes a PowerShell script and returns its stdout. -// -// powershell.exe rather than pwsh: everything this agent runs through here -// touches Windows Update COM or CIM, both of which are most reliable under -// Windows PowerShell 5.1, and 5.1 is present on every supported Windows while -// pwsh is an optional install. -func Run(ctx context.Context, script string) (string, error) { - cmd := exec.CommandContext(ctx, "powershell.exe", - "-NoProfile", "-NonInteractive", "-EncodedCommand", EncodeCommand(script)) - - out, err := cmd.Output() - if err != nil { - // Checked before the ExitError/stderr branch: CommandContext kills the - // process on timeout, and that kill can itself produce an ExitError - // carrying stderr text, so a genuine timeout would otherwise surface - // as that stderr instead of the "timed out" message callers match on. - if ctx.Err() == context.DeadlineExceeded { - return "", fmt.Errorf("powershell: timed out") - } - if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { - return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr))) - } - return "", fmt.Errorf("powershell: %w", err) - } - return string(out), nil -} diff --git a/agent/internal/workloads/control.go b/agent/internal/workloads/control.go deleted file mode 100644 index 9f838e0..0000000 --- a/agent/internal/workloads/control.go +++ /dev/null @@ -1,64 +0,0 @@ -package workloads - -import ( - "context" - "errors" - "fmt" - "strings" - "time" -) - -// ErrProtected is returned for a workload the agent will not act on. -var ErrProtected = errors.New("workload is protected") - -// controlTimeout bounds a stop that may never finish on its own. `docker stop` -// waits on a container that may ignore SIGTERM, and both systemctl and -// Stop-Service block for as long as the unit's own stop timeout says. A -// timeout must return a real error rather than an ack implying success. -const controlTimeout = 90 * time.Second - -// isProtected reports whether the agent refuses to act on this workload. -// -// The refusal lives here, in the agent, and not in the control plane. As with -// the console relay hardcoding 127.0.0.1 agent-side: the control plane may name -// a target, but the agent decides what it will do to itself. A server-side -// denylist alone would be bypassed by the next dispatch path someone adds. -func isProtected(kind, id, name string) bool { - if kind == "unit" { - return isProtectedUnit(id, name) - } - if ownContainerID == "" { - return false - } - // Container IDs are commonly abbreviated to 12 characters; compare on the - // shorter of the two so a short id still matches a full one. - return strings.HasPrefix(ownContainerID, id) || strings.HasPrefix(id, ownContainerID) -} - -// markProtected stamps the flag onto a collected list so the UI can render the -// action disabled with a reason. -func markProtected(wls []Workload) { - for i := range wls { - wls[i].Protected = isProtected(wls[i].Kind, wls[i].ID, wls[i].Name) - } -} - -// Control starts, stops or restarts a workload. -func Control(ctx context.Context, kind, id, action string) error { - switch action { - case "start", "stop", "restart": - default: - return fmt.Errorf("unknown action %q", action) - } - - // Checked before anything else happens, and checked here rather than only - // on the server. See isProtected. - if isProtected(kind, id, strings.TrimSuffix(id, ".service")) { - return fmt.Errorf("%w: %s", ErrProtected, id) - } - - ctx, cancel := context.WithTimeout(ctx, controlTimeout) - defer cancel() - - return controlPlatform(ctx, kind, id, action) -} diff --git a/agent/internal/workloads/control_linux.go b/agent/internal/workloads/control_linux.go deleted file mode 100644 index 9c1977d..0000000 --- a/agent/internal/workloads/control_linux.go +++ /dev/null @@ -1,56 +0,0 @@ -package workloads - -import ( - "context" - "fmt" - "os" - "os/exec" - "regexp" - "strings" -) - -// AgentUnit is the systemd unit this agent runs as. -const AgentUnit = "vantage-agent.service" - -// ownContainerID is read once: the container this agent runs in, if any. -var ownContainerID = detectOwnContainer() - -var cgroupContainerRe = regexp.MustCompile(`[0-9a-f]{64}`) - -// detectOwnContainer returns this process's container ID, or "" on a host -// install. The agent is normally a systemd service, so "" is the common case; -// this exists so containerising it later cannot silently remove the guard. -func detectOwnContainer() string { - b, err := os.ReadFile("/proc/self/cgroup") - if err != nil { - return "" - } - if m := cgroupContainerRe.FindString(string(b)); m != "" { - return m - } - return "" -} - -func isProtectedUnit(id, name string) bool { - return id == AgentUnit || name == strings.TrimSuffix(AgentUnit, ".service") -} - -func controlPlatform(ctx context.Context, kind, id, action string) error { - var cmd *exec.Cmd - switch kind { - case "container": - cmd = exec.CommandContext(ctx, "docker", action, id) - case "unit": - cmd = exec.CommandContext(ctx, "systemctl", action, id) - default: - return fmt.Errorf("unknown workload kind %q", kind) - } - - if out, err := cmd.CombinedOutput(); err != nil { - if ctx.Err() == context.DeadlineExceeded { - return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout) - } - return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out))) - } - return nil -} diff --git a/agent/internal/workloads/control_windows.go b/agent/internal/workloads/control_windows.go deleted file mode 100644 index 6fabf88..0000000 --- a/agent/internal/workloads/control_windows.go +++ /dev/null @@ -1,74 +0,0 @@ -package workloads - -import ( - "context" - "fmt" - "os/exec" - "strings" - - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec" -) - -// AgentUnit is the service this agent runs as — the NSSM service name written -// by installer/setup.ps1. Change one, change the other. -const AgentUnit = "VantageAgent" - -// A Windows agent is never itself in a container; the Linux build reads -// /proc/self/cgroup, and there is no equivalent question to ask here. -var ownContainerID = "" - -// Windows service names are case-insensitive, so the comparison must be too. -func isProtectedUnit(id, name string) bool { - return strings.EqualFold(id, AgentUnit) || strings.EqualFold(name, AgentUnit) -} - -func controlPlatform(ctx context.Context, kind, id, action string) error { - switch kind { - case "container": - // Docker behaves identically on Windows, so this path is shared in - // spirit with the Linux one rather than routed through PowerShell. - cmd := exec.CommandContext(ctx, "docker", action, id) - if out, err := cmd.CombinedOutput(); err != nil { - if ctx.Err() == context.DeadlineExceeded { - return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout) - } - return fmt.Errorf("%s %s: %s", action, id, strings.TrimSpace(string(out))) - } - return nil - - case "unit": - // -Force is required: Stop-Service without it refuses outright when - // another service depends on the target, and that refusal reads to an - // operator as a silent no-op. - // - // sc.exe is avoided because it returns before the operation completes, - // which turns a timeout into a false success. - var verb string - switch action { - case "start": - verb = "Start-Service" - case "stop": - verb = "Stop-Service" - case "restart": - verb = "Restart-Service" - default: - return fmt.Errorf("unknown action %q", action) - } - - script := "$ErrorActionPreference='Stop'\n" + verb + " -Name " + psQuote(id) - if action != "start" { - script += " -Force" - } - - if _, err := winexec.Run(ctx, script); err != nil { - if ctx.Err() == context.DeadlineExceeded { - return fmt.Errorf("%s %s timed out after %s", action, id, controlTimeout) - } - return fmt.Errorf("%s %s: %w", action, id, err) - } - return nil - - default: - return fmt.Errorf("unknown workload kind %q", kind) - } -} diff --git a/agent/internal/workloads/docker.go b/agent/internal/workloads/docker.go deleted file mode 100644 index 8584154..0000000 --- a/agent/internal/workloads/docker.go +++ /dev/null @@ -1,141 +0,0 @@ -package workloads - -import ( - "context" - "encoding/json" - "os/exec" - "sort" - "strings" - "time" -) - -// Workload is one container or one systemd unit, agent-side. It mirrors -// models.Workload on the server. -type Workload struct { - Kind string - ID string - Name string - State string - Health string - Image string - Stack string - Ports []string - Restarts int - StartedAt time.Time - Protected bool -} - -const dockerTimeout = 30 * time.Second - -// dockerInspect is the subset of `docker inspect` output we read. -// -// We use inspect rather than `docker ps --format '{{json .}}'` because ps -// reports health and uptime inside a human Status string — "Up 2 hours -// (healthy)" — and anything built on that is parsing English that is -// localised, reworded between releases, and silently different for a paused or -// restarting container. inspect gives typed fields instead. -type dockerInspect struct { - ID string `json:"Id"` - Name string `json:"Name"` - State struct { - Status string `json:"Status"` - StartedAt string `json:"StartedAt"` - Restarting bool `json:"Restarting"` - Health *struct { - Status string `json:"Status"` - } `json:"Health"` - } `json:"State"` - Config struct { - Image string `json:"Image"` - Labels map[string]string `json:"Labels"` - } `json:"Config"` - RestartCount int `json:"RestartCount"` - NetworkSettings struct { - Ports map[string][]struct { - HostIP string `json:"HostIp"` - HostPort string `json:"HostPort"` - } `json:"Ports"` - } `json:"NetworkSettings"` -} - -// collectDocker enumerates containers. It returns ok=false with an empty error -// string when Docker is simply not installed — the common case on this fleet, -// and not a fault. -func collectDocker(ctx context.Context) ([]Workload, bool, string) { - if _, err := exec.LookPath("docker"); err != nil { - return nil, false, "" // not installed; not an error - } - - ctx, cancel := context.WithTimeout(ctx, dockerTimeout) - defer cancel() - - idsOut, err := exec.CommandContext(ctx, "docker", "ps", "-aq").Output() - if err != nil { - // Installed but not answering: a different problem with a different - // fix, so it carries a message where "not installed" does not. - return nil, false, "docker ps failed: " + errText(err) - } - - ids := strings.Fields(string(idsOut)) - if len(ids) == 0 { - return []Workload{}, true, "" // Docker present, nothing running - } - - args := append([]string{"inspect", "--format", "{{json .}}"}, ids...) - out, err := exec.CommandContext(ctx, "docker", args...).Output() - if err != nil { - return nil, false, "docker inspect failed: " + errText(err) - } - - var wls []Workload - for _, line := range strings.Split(string(out), "\n") { - line = strings.TrimSpace(line) - if line == "" { - continue - } - var di dockerInspect - if err := json.Unmarshal([]byte(line), &di); err != nil { - continue - } - wls = append(wls, dockerToWorkload(di)) - } - return wls, true, "" -} - -func dockerToWorkload(di dockerInspect) Workload { - w := Workload{ - Kind: "container", - ID: di.ID, - Name: strings.TrimPrefix(di.Name, "/"), - State: di.State.Status, - Image: di.Config.Image, - Restarts: di.RestartCount, - } - if di.State.Health != nil { - w.Health = strings.ToLower(di.State.Health.Status) - } - // The compose project label is what Docker itself treats as authoritative. - // No YAML is read from disk: a compose file there may not be what is running. - if v := di.Config.Labels["com.docker.compose.project"]; v != "" { - w.Stack = v - } - if t, err := time.Parse(time.RFC3339Nano, di.State.StartedAt); err == nil { - w.StartedAt = t - } - for container, bindings := range di.NetworkSettings.Ports { - for _, b := range bindings { - w.Ports = append(w.Ports, b.HostIP+":"+b.HostPort+"->"+container) - } - } - // Map iteration order is random; sort so a stored snapshot does not reorder - // its own ports between two otherwise identical reports. - sort.Strings(w.Ports) - return w -} - -func errText(err error) string { - if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { - return strings.TrimSpace(string(ee.Stderr)) - } - return err.Error() -} diff --git a/agent/internal/workloads/logs.go b/agent/internal/workloads/logs.go deleted file mode 100644 index 45c5ed5..0000000 --- a/agent/internal/workloads/logs.go +++ /dev/null @@ -1,66 +0,0 @@ -package workloads - -import ( - "context" - "strings" - "time" -) - -const ( - // MaxLogLines and MaxLogBytes are BOTH enforced, whichever binds first. - // - // A line count alone does not bound size: 500 lines of a container printing - // 4KB JSON blobs is 2MB travelling over the bus. This is the same reasoning - // that gave workflow logs a per-line cap as well as a per-run one. - MaxLogLines = 500 - MaxLogBytes = 256 * 1024 - - logTimeout = 60 * time.Second -) - -// Logs returns a bounded snapshot of a workload's recent output. -// -// There is no follow mode. The browser console already offers a real terminal -// on the same server where `docker logs -f` works properly, with its own -// scrollback and cancellation. A snapshot answers "why did this restart", -// which is the question that sends people to the console in the first place. -func Logs(ctx context.Context, kind, id string, tail int) (string, bool, error) { - if tail <= 0 || tail > MaxLogLines { - tail = MaxLogLines - } - - ctx, cancel := context.WithTimeout(ctx, logTimeout) - defer cancel() - - out, err := logsPlatform(ctx, kind, id, tail) - if err != nil { - return "", false, err - } - - text, truncated := capLog(out) - return text, truncated, nil -} - -// capLog enforces both limits, trimming from the FRONT: the most recent lines -// are the ones worth keeping. -func capLog(s string) (string, bool) { - truncated := false - - lines := strings.Split(s, "\n") - if len(lines) > MaxLogLines { - lines = lines[len(lines)-MaxLogLines:] - truncated = true - } - s = strings.Join(lines, "\n") - - if len(s) > MaxLogBytes { - s = s[len(s)-MaxLogBytes:] - // Drop the leading partial line left by a byte-wise cut. - if i := strings.IndexByte(s, '\n'); i >= 0 { - s = s[i+1:] - } - truncated = true - } - - return s, truncated -} diff --git a/agent/internal/workloads/logs_linux.go b/agent/internal/workloads/logs_linux.go deleted file mode 100644 index efc47c9..0000000 --- a/agent/internal/workloads/logs_linux.go +++ /dev/null @@ -1,30 +0,0 @@ -package workloads - -import ( - "context" - "fmt" - "os/exec" - "strconv" -) - -func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error) { - var cmd *exec.Cmd - switch kind { - case "container": - cmd = exec.CommandContext(ctx, "docker", "logs", - "--tail", strconv.Itoa(tail), "--timestamps", id) - case "unit": - cmd = exec.CommandContext(ctx, "journalctl", "-u", id, - "-n", strconv.Itoa(tail), "--no-pager", "--output=short-iso") - default: - return "", fmt.Errorf("unknown workload kind %q", kind) - } - - // docker logs writes container stderr to our stderr, so both streams must - // be captured or half the output silently disappears. - out, err := cmd.CombinedOutput() - if err != nil && len(out) == 0 { - return "", fmt.Errorf("read logs for %s: %s", id, errText(err)) - } - return string(out), nil -} diff --git a/agent/internal/workloads/logs_windows.go b/agent/internal/workloads/logs_windows.go deleted file mode 100644 index 817d850..0000000 --- a/agent/internal/workloads/logs_windows.go +++ /dev/null @@ -1,89 +0,0 @@ -package workloads - -import ( - "context" - "fmt" - "os/exec" - "strconv" - - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec" -) - -func logsPlatform(ctx context.Context, kind, id string, tail int) (string, error) { - switch kind { - case "container": - cmd := exec.CommandContext(ctx, "docker", "logs", - "--tail", strconv.Itoa(tail), "--timestamps", id) - out, err := cmd.CombinedOutput() - if err != nil && len(out) == 0 { - return "", fmt.Errorf("read logs for %s: %s", id, errText(err)) - } - return string(out), nil - - case "unit": - display := serviceDisplayName(ctx, id) - - // Timestamps are formatted PowerShell-side rather than left to - // ConvertTo-Json, whose DateTime rendering differs between PowerShell - // versions — one of them emits /Date(1699...)/. - // - // $ErrorActionPreference = 'SilentlyContinue' because Get-WinEvent - // treats "no events matched" as a terminating error, and a quiet - // service is normal. - names := psQuote(id) - if display != "" && display != id { - names += "," + psQuote(display) - } - names += "," + psQuote(scmProvider) - - // ProviderName includes the host-wide Service Control Manager, so a - // -MaxEvents cap of exactly tail would apply to the combined stream - // before parseEvents narrows SCM rows down to this service — on a - // host with busy service churn the target's own events could be - // squeezed out of the window entirely. Over-fetch instead, hard-capped - // so a pathological host cannot pull an unbounded batch across the - // wire, and let parseEvents trim to the last tail lines after - // filtering. - fetch := tail * 5 - if fetch > 2500 { - fetch = 2500 - } - - script := ` -$ErrorActionPreference = 'SilentlyContinue' -$rows = Get-WinEvent -FilterHashtable @{LogName='System','Application'; ProviderName=@(` + names + `)} ` + - `-MaxEvents ` + strconv.Itoa(fetch) + ` | - ForEach-Object { - [pscustomobject]@{ - t = $_.TimeCreated.ToUniversalTime().ToString('o') - l = [string]$_.LevelDisplayName - p = [string]$_.ProviderName - m = [string]$_.Message - } - } -ConvertTo-Json -InputObject @($rows) -Depth 3 -Compress -` - - out, err := winexec.Run(ctx, script) - if err != nil { - return "", fmt.Errorf("read events for %s: %w", id, err) - } - return parseEvents(out, id, display, tail) - - default: - return "", fmt.Errorf("unknown workload kind %q", kind) - } -} - -// serviceDisplayName resolves a service's display name, which is what Service -// Control Manager events name it by. An empty answer is fine — the filter then -// matches on the service name alone. -func serviceDisplayName(ctx context.Context, id string) string { - out, err := winexec.Run(ctx, - "$ErrorActionPreference='SilentlyContinue'\n"+ - "(Get-Service -Name "+psQuote(id)+").DisplayName") - if err != nil { - return "" - } - return trimLine(out) -} diff --git a/agent/internal/workloads/services_windows.go b/agent/internal/workloads/services_windows.go deleted file mode 100644 index b6790cc..0000000 --- a/agent/internal/workloads/services_windows.go +++ /dev/null @@ -1,50 +0,0 @@ -package workloads - -import ( - "context" - "os" - "time" - - "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec" -) - -const servicesTimeout = 60 * time.Second - -const servicesScript = ` -$ErrorActionPreference = 'Stop' -$svcs = Get-CimInstance Win32_Service | ForEach-Object { - [pscustomobject]@{ - Name = $_.Name - DisplayName = $_.DisplayName - State = $_.State - StartMode = $_.StartMode - PathName = $_.PathName - ExitCode = $_.ExitCode - } -} -ConvertTo-Json -InputObject @($svcs) -Depth 3 -Compress -` - -// collectUnits enumerates Windows services. The bool and string it returns are -// the same SystemdOK / SystemdError pair the Linux collector fills: the wire -// shape is shared, and the UI words it per platform. -func collectUnits(ctx context.Context) ([]Workload, bool, string) { - ctx, cancel := context.WithTimeout(ctx, servicesTimeout) - defer cancel() - - out, err := winexec.Run(ctx, servicesScript) - if err != nil { - return nil, false, "Win32_Service query failed: " + err.Error() - } - - systemRoot := os.Getenv("SystemRoot") - if systemRoot == "" { - systemRoot = `C:\Windows` - } - - wls, err := parseServices(out, systemRoot) - if err != nil { - return nil, false, "Win32_Service output could not be read: " + err.Error() - } - return wls, true, "" -} diff --git a/agent/internal/workloads/systemd_linux.go b/agent/internal/workloads/systemd_linux.go deleted file mode 100644 index 49a4c58..0000000 --- a/agent/internal/workloads/systemd_linux.go +++ /dev/null @@ -1,94 +0,0 @@ -package workloads - -import ( - "context" - "os/exec" - "strings" - "time" -) - -const systemdTimeout = 30 * time.Second - -// excludedPrefixes drops the platform's own units. A typical host carries 300+ -// units and systemd accounts for most of them; listing all of them buries the -// ten anyone cares about. -var excludedPrefixes = []string{"systemd-", "user@", "user-", "session-", "init.scope"} - -// collectUnits enumerates services in two passes, because "running or -// failed" and "enabled but stopped" are different questions — and an enabled -// unit that is not running is exactly the one worth seeing. -func collectUnits(ctx context.Context) ([]Workload, bool, string) { - if _, err := exec.LookPath("systemctl"); err != nil { - return nil, false, "" - } - - ctx, cancel := context.WithTimeout(ctx, systemdTimeout) - defer cancel() - - // Column output rather than --output=json: the JSON flag needs systemd - // 246+, and this fleet includes older stable distributions. The columns - // have been stable considerably longer than the JSON has existed. - unitsOut, err := exec.CommandContext(ctx, "systemctl", - "list-units", "--type=service", "--state=running,failed", - "--no-legend", "--plain", "--no-pager").Output() - if err != nil { - return nil, false, "systemctl list-units failed: " + errText(err) - } - - seen := map[string]bool{} - var wls []Workload - - for _, line := range strings.Split(string(unitsOut), "\n") { - f := strings.Fields(line) - // UNIT LOAD ACTIVE SUB DESCRIPTION… - if len(f) < 4 { - continue - } - name := f[0] - if excluded(name) || seen[name] { - continue - } - seen[name] = true - wls = append(wls, Workload{ - Kind: "unit", - ID: name, - Name: strings.TrimSuffix(name, ".service"), - State: f[2], // ACTIVE: active | failed | activating | inactive - }) - } - - filesOut, err := exec.CommandContext(ctx, "systemctl", - "list-unit-files", "--type=service", "--state=enabled", - "--no-legend", "--plain", "--no-pager").Output() - if err == nil { - for _, line := range strings.Split(string(filesOut), "\n") { - f := strings.Fields(line) - // UNIT FILE STATE [PRESET] - if len(f) < 2 { - continue - } - name := f[0] - if excluded(name) || seen[name] { - continue - } - seen[name] = true - wls = append(wls, Workload{ - Kind: "unit", - ID: name, - Name: strings.TrimSuffix(name, ".service"), - State: "inactive", // enabled but not currently running - }) - } - } - - return wls, true, "" -} - -func excluded(name string) bool { - for _, p := range excludedPrefixes { - if strings.HasPrefix(name, p) { - return true - } - } - return false -} diff --git a/agent/internal/workloads/units_other.go b/agent/internal/workloads/units_other.go deleted file mode 100644 index 3d15b08..0000000 --- a/agent/internal/workloads/units_other.go +++ /dev/null @@ -1,22 +0,0 @@ -//go:build !linux && !windows - -// The build constraint is load-bearing — see updates_other.go. -package workloads - -import ( - "context" - "fmt" -) - -var ownContainerID = "" - -func collectUnits(context.Context) ([]Workload, bool, string) { return nil, false, "" } -func isProtectedUnit(string, string) bool { return false } - -func controlPlatform(context.Context, string, string, string) error { - return fmt.Errorf("workload control is not supported on this platform") -} - -func logsPlatform(context.Context, string, string, int) (string, error) { - return "", fmt.Errorf("workload logs are not supported on this platform") -} diff --git a/agent/internal/workloads/winparse.go b/agent/internal/workloads/winparse.go deleted file mode 100644 index 5aaa20b..0000000 --- a/agent/internal/workloads/winparse.go +++ /dev/null @@ -1,224 +0,0 @@ -package workloads - -import ( - "encoding/json" - "strings" -) - -// winService is one row of Get-CimInstance Win32_Service. -// -// Win32_Service rather than Get-Service: Get-Service exposes neither PathName -// nor StartMode, and the filter below needs both. -type winService struct { - Name string `json:"Name"` - DisplayName string `json:"DisplayName"` - State string `json:"State"` - StartMode string `json:"StartMode"` - PathName string `json:"PathName"` - ExitCode int `json:"ExitCode"` -} - -// exitCodeNeverStarted is ERROR_SERVICE_NEVER_STARTED. A stopped service -// carrying it has not failed — it has not run since boot — and painting that -// red would cry wolf on every host. -const exitCodeNeverStarted = 1077 - -// servicePath extracts the executable from a Win32_Service PathName. -// -// A naive split on whitespace misfiles a substantial share of a real fleet: -// `"C:\Program Files\X\x.exe" -service` is one path and one argument. -func servicePath(pathName string) string { - s := strings.TrimSpace(pathName) - if s == "" { - return "" - } - if s[0] == '"' { - if end := strings.IndexByte(s[1:], '"'); end >= 0 { - return s[1 : 1+end] - } - // No closing quote: a malformed or truncated PathName. Fall back to - // the unquoted handling below on the text after the opening quote, - // so this yields a bare path rather than a path plus trailing - // argument text. - s = s[1:] - } - if i := exeBoundaryIndex(s); i >= 0 { - return s[:i+len(".exe")] - } - if i := strings.IndexAny(s, " \t"); i >= 0 { - return s[:i] - } - return s -} - -// exeBoundaryIndex finds the first ".exe" (case-insensitive) in s that -// actually ends the executable name — followed by end-of-string, whitespace, -// or a double quote — rather than continuing into a longer segment such as -// ".exec". It returns -1 when no such occurrence exists, so a path like -// `C:\Program Files\Ad.exec\tool.com -flag` is not misparsed by matching the -// ".exe" inside "Ad.exec" and silently dropping the real filename. -func exeBoundaryIndex(s string) int { - lower := strings.ToLower(s) - from := 0 - for { - rel := strings.Index(lower[from:], ".exe") - if rel < 0 { - return -1 - } - idx := from + rel - end := idx + len(".exe") - if end == len(s) || s[end] == ' ' || s[end] == '\t' || s[end] == '"' { - return idx - } - from = idx + 1 - } -} - -// parseServices turns the collector's JSON into workloads. -// -// systemRoot is a parameter rather than an environment read so this is testable -// off Windows. The caller passes %SystemRoot%. -// -// The filter mirrors the systemd collector's intent: show what an operator -// installed, and show what is meant to be up but is not. Services under -// %SystemRoot%\System32 are the platform's own, and a typical host has well -// over a hundred of them. -func parseServices(jsonText, systemRoot string) ([]Workload, error) { - s := strings.TrimSpace(jsonText) - if s == "" || s == "null" { - return nil, nil - } - - var rows []winService - if err := json.Unmarshal([]byte(s), &rows); err != nil { - var one winService - if err2 := json.Unmarshal([]byte(s), &one); err2 != nil { - return nil, err - } - rows = []winService{one} - } - - sys32 := strings.ToLower(strings.TrimRight(systemRoot, `\`) + `\system32\`) - - var wls []Workload - for _, r := range rows { - if p := strings.ToLower(servicePath(r.PathName)); p != "" && strings.HasPrefix(p, sys32) { - continue - } - - running := strings.EqualFold(r.State, "Running") - failed := !running && r.ExitCode != 0 && r.ExitCode != exitCodeNeverStarted - auto := strings.HasPrefix(strings.ToLower(r.StartMode), "auto") - if !running && !failed && !auto { - continue - } - - // The wire shape is shared with the systemd collector — both report - // under kind "unit" — so the state word has to be too, or the UI - // (which colours and filters on it, and does so before it knows - // which platform sent the row) needs two vocabularies for one kind. - // running/stopped/failed become active/inactive/failed to match. - state := "inactive" - switch { - case running: - state = "active" - case failed: - state = "failed" - } - - name := r.DisplayName - if name == "" { - name = r.Name - } - - wls = append(wls, Workload{ - Kind: "unit", - ID: r.Name, - Name: name, - State: state, - }) - } - return wls, nil -} - -// psQuote renders a Go string as a PowerShell single-quoted literal. Single -// quotes suppress every form of expansion, so the only character needing an -// escape is the quote itself, which is doubled. -func psQuote(s string) string { - return "'" + strings.ReplaceAll(s, "'", "''") + "'" -} - -// scmProvider is the provider every service's start and stop is logged under, -// host-wide. -const scmProvider = "Service Control Manager" - -type winEvent struct { - T string `json:"t"` - L string `json:"l"` - P string `json:"p"` - M string `json:"m"` -} - -// parseEvents renders Get-WinEvent output as text in the shape journalctl -// --output=short-iso produces, so the log dialog needs no per-platform -// rendering: " ", oldest first. -// -// The caller over-fetches from Get-WinEvent because the ProviderName filter -// includes the host-wide Service Control Manager, and a -MaxEvents cap -// applied before SCM rows are narrowed down to this service would squeeze the -// target's own events out of the window on a host with busy service churn. -// tail is therefore applied here, AFTER filtering and AFTER the oldest-first -// reversal, keeping the last tail lines — the most recent lines are the ones -// worth keeping, matching capLog's front-trim reasoning in the shared -// logs.go. -func parseEvents(jsonText, serviceName, displayName string, tail int) (string, error) { - s := strings.TrimSpace(jsonText) - if s == "" || s == "null" { - return "", nil - } - - var rows []winEvent - if err := json.Unmarshal([]byte(s), &rows); err != nil { - var one winEvent - if err2 := json.Unmarshal([]byte(s), &one); err2 != nil { - return "", err - } - rows = []winEvent{one} - } - - var lines []string - for _, e := range rows { - if strings.EqualFold(e.P, scmProvider) { - if !strings.Contains(e.M, serviceName) && - (displayName == "" || !strings.Contains(e.M, displayName)) { - continue - } - } - // Collapse every newline form, not just "\r\n": a message containing a - // bare "\n" would otherwise still break the one-line-per-event shape - // this renders for the log dialog, and undercount the tail trim above. - msg := strings.TrimSpace(strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(e.M)) - lines = append(lines, e.T+" "+e.L+" "+msg) - } - - // Get-WinEvent is newest-first. Reverse it. - for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { - lines[i], lines[j] = lines[j], lines[i] - } - - if tail > 0 && len(lines) > tail { - lines = lines[len(lines)-tail:] - } - - return strings.Join(lines, "\n"), nil -} - -// trimLine reduces single-value PowerShell output to its first non-empty line. -func trimLine(s string) string { - for _, l := range strings.Split(s, "\n") { - if t := strings.TrimSpace(l); t != "" { - return t - } - } - return "" -} diff --git a/agent/internal/workloads/winparse_test.go b/agent/internal/workloads/winparse_test.go deleted file mode 100644 index 9106bea..0000000 --- a/agent/internal/workloads/winparse_test.go +++ /dev/null @@ -1,207 +0,0 @@ -package workloads - -import ( - "strings" - "testing" -) - -func TestServicePath(t *testing.T) { - cases := []struct{ in, want string }{ - {`"C:\Program Files\Contoso\svc.exe" -service`, `C:\Program Files\Contoso\svc.exe`}, - {`C:\WINDOWS\system32\svchost.exe -k netsvcs`, `C:\WINDOWS\system32\svchost.exe`}, - {`C:\Vantage\vantage-agent.exe`, `C:\Vantage\vantage-agent.exe`}, - {`"C:\no\args.exe"`, `C:\no\args.exe`}, - {``, ``}, - // ".exe" appearing inside an earlier segment ("Ad.exec") must not be - // treated as the end of the executable — that would drop the real - // filename and arguments. - {`C:\Program Files\Ad.exec\tool.com -flag`, `C:\Program`}, - // An unterminated quote falls back to the unquoted handling on the - // text after the opening quote, yielding a bare path rather than a - // path plus trailing argument text. - {`"C:\Program Files\Contoso\svc.exe -service`, `C:\Program Files\Contoso\svc.exe`}, - } - for _, c := range cases { - if got := servicePath(c.in); got != c.want { - t.Errorf("servicePath(%q) = %q, want %q", c.in, got, c.want) - } - } -} - -func TestParseServicesFilters(t *testing.T) { - in := `[ - {"Name":"Contoso","DisplayName":"Contoso Broker","State":"Running","StartMode":"Auto","PathName":"\"C:\\Program Files\\Contoso\\svc.exe\" -service","ExitCode":0}, - {"Name":"Themes","DisplayName":"Themes","State":"Running","StartMode":"Auto","PathName":"C:\\WINDOWS\\system32\\svchost.exe -k netsvcs","ExitCode":0}, - {"Name":"Fabrikam","DisplayName":"Fabrikam Sync","State":"Stopped","StartMode":"Auto","PathName":"C:\\Fabrikam\\sync.exe","ExitCode":0}, - {"Name":"Northwind","DisplayName":"Northwind Poller","State":"Stopped","StartMode":"Manual","PathName":"C:\\Northwind\\poll.exe","ExitCode":0}, - {"Name":"Crashed","DisplayName":"Crashed Thing","State":"Stopped","StartMode":"Auto","PathName":"C:\\Crashed\\c.exe","ExitCode":1067} - ]` - - got, err := parseServices(in, `C:\WINDOWS`) - if err != nil { - t.Fatalf("parseServices: %v", err) - } - - byID := map[string]Workload{} - for _, w := range got { - byID[w.ID] = w - } - - // The OS's own svchost service is dropped; a manual, stopped, never-failed - // service is nobody's business either. - if _, ok := byID["Themes"]; ok { - t.Error("Themes (under %SystemRoot%) should be filtered out") - } - if _, ok := byID["Northwind"]; ok { - t.Error("stopped Manual service should be filtered out") - } - if len(got) != 3 { - t.Fatalf("got %d workloads, want 3: %+v", len(got), got) - } - - if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "active" { - t.Errorf("Contoso = %+v", w) - } - // Enabled but not running is exactly the row worth seeing. - if byID["Fabrikam"].State != "inactive" { - t.Errorf("Fabrikam state = %q, want inactive", byID["Fabrikam"].State) - } - // A non-zero exit code on a stopped service is a crash, not a clean stop. - if byID["Crashed"].State != "failed" { - t.Errorf("Crashed state = %q, want failed", byID["Crashed"].State) - } -} - -// 1077 means "no attempt to start since boot" — a clean stopped service, not a -// failure, and reporting it red would cry wolf on every host. -func TestParseServicesExitCode1077(t *testing.T) { - in := `[{"Name":"Idle","DisplayName":"Idle","State":"Stopped","StartMode":"Auto","PathName":"C:\\Idle\\i.exe","ExitCode":1077}]` - got, err := parseServices(in, `C:\WINDOWS`) - if err != nil { - t.Fatalf("parseServices: %v", err) - } - if len(got) != 1 || got[0].State != "inactive" { - t.Fatalf("got %+v, want one inactive workload", got) - } -} - -func TestParseServicesSingleObjectAndEmpty(t *testing.T) { - one := `{"Name":"Solo","DisplayName":"Solo","State":"Running","StartMode":"Auto","PathName":"C:\\Solo\\s.exe","ExitCode":0}` - got, err := parseServices(one, `C:\WINDOWS`) - if err != nil || len(got) != 1 || got[0].State != "active" { - t.Fatalf("single object: got %+v, err %v", got, err) - } - - for _, in := range []string{"", "[]", "null"} { - got, err := parseServices(in, `C:\WINDOWS`) - if err != nil || len(got) != 0 { - t.Fatalf("parseServices(%q) = %+v, err %v", in, got, err) - } - } -} - -func TestPSQuote(t *testing.T) { - if got := psQuote(`it's`); got != `'it''s'` { - t.Fatalf("psQuote = %s", got) - } - if got := psQuote(`plain`); got != `'plain'` { - t.Fatalf("psQuote = %s", got) - } -} - -func TestParseEventsFormatsAndOrders(t *testing.T) { - // Get-WinEvent returns newest first; journalctl --output=short-iso returns - // oldest first, and the log dialog and capLog's front-trim both assume the - // most recent line is at the bottom. - in := `[ - {"t":"2026-08-13T10:22:31.0000000Z","l":"Error","p":"Contoso","m":"broker died"}, - {"t":"2026-08-13T10:22:03.0000000Z","l":"Information","p":"Contoso","m":"broker starting"} - ]` - - got, err := parseEvents(in, "Contoso", "Contoso Broker", 500) - if err != nil { - t.Fatalf("parseEvents: %v", err) - } - - want := "2026-08-13T10:22:03.0000000Z Information broker starting\n" + - "2026-08-13T10:22:31.0000000Z Error broker died" - if got != want { - t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want) - } -} - -// A message containing a bare "\n" (no carriage return) must still collapse to -// one line, or it silently multiplies into several output lines and throws -// off the tail trim's count. -func TestParseEventsCollapsesBareLF(t *testing.T) { - in := `[{"t":"2026-08-13T10:00:00Z","l":"Error","p":"Contoso","m":"broker died\nstack trace here"}]` - - got, err := parseEvents(in, "Contoso", "Contoso Broker", 500) - if err != nil { - t.Fatalf("parseEvents: %v", err) - } - if strings.Count(got, "\n") != 0 { - t.Fatalf("parseEvents did not collapse bare LF into one line: %q", got) - } - want := "2026-08-13T10:00:00Z Error broker died stack trace here" - if got != want { - t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want) - } -} - -// Service Control Manager logs every service on the host under one provider, so -// its rows must be filtered down to the target or the log is somebody else's. -func TestParseEventsFiltersOtherServicesSCM(t *testing.T) { - in := `[ - {"t":"2026-08-13T10:00:00Z","l":"Information","p":"Service Control Manager","m":"The Print Spooler service entered the running state."}, - {"t":"2026-08-13T10:00:01Z","l":"Information","p":"Service Control Manager","m":"The Contoso Broker service entered the running state."} - ]` - - got, err := parseEvents(in, "Contoso", "Contoso Broker", 500) - if err != nil { - t.Fatalf("parseEvents: %v", err) - } - if strings.Contains(got, "Print Spooler") { - t.Errorf("another service's SCM event leaked in:\n%s", got) - } - if !strings.Contains(got, "Contoso Broker") { - t.Errorf("the target's SCM event was dropped:\n%s", got) - } -} - -// A service that has logged nothing is normal. An error there would read as a -// broken feature. -func TestParseEventsEmpty(t *testing.T) { - for _, in := range []string{"", "[]", "null"} { - got, err := parseEvents(in, "Contoso", "Contoso Broker", 500) - if err != nil || got != "" { - t.Fatalf("parseEvents(%q) = %q, err %v", in, got, err) - } - } -} - -// The over-fetch in logs_windows.go can return more events than the caller -// asked for once SCM rows are filtered down to the target; parseEvents must -// keep the most RECENT tail lines, not the oldest, matching capLog's -// front-trim reasoning in the shared logs.go. -func TestParseEventsTrimsToTailKeepingMostRecent(t *testing.T) { - in := `[ - {"t":"2026-08-13T10:00:06Z","l":"Information","p":"Contoso","m":"event 6"}, - {"t":"2026-08-13T10:00:05Z","l":"Information","p":"Contoso","m":"event 5"}, - {"t":"2026-08-13T10:00:04Z","l":"Information","p":"Contoso","m":"event 4"}, - {"t":"2026-08-13T10:00:03Z","l":"Information","p":"Contoso","m":"event 3"}, - {"t":"2026-08-13T10:00:02Z","l":"Information","p":"Contoso","m":"event 2"}, - {"t":"2026-08-13T10:00:01Z","l":"Information","p":"Contoso","m":"event 1"} - ]` - - got, err := parseEvents(in, "Contoso", "Contoso Broker", 2) - if err != nil { - t.Fatalf("parseEvents: %v", err) - } - - want := "2026-08-13T10:00:05Z Information event 5\n" + - "2026-08-13T10:00:06Z Information event 6" - if got != want { - t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want) - } -} diff --git a/agent/internal/workloads/workloads.go b/agent/internal/workloads/workloads.go deleted file mode 100644 index c516d3f..0000000 --- a/agent/internal/workloads/workloads.go +++ /dev/null @@ -1,60 +0,0 @@ -package workloads - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "sort" - "strconv" - "strings" -) - -// Result is one collection pass. -type Result struct { - Workloads []Workload - DockerOK bool - DockerError string - SystemdOK bool - SystemdError string -} - -// Collect enumerates every workload on this host: containers from Docker, and -// units from systemd on Linux or the service control manager on Windows. -func Collect(ctx context.Context) Result { - var r Result - containers, dockerOK, dockerErr := collectDocker(ctx) - units, systemdOK, systemdErr := collectUnits(ctx) - - r.DockerOK, r.DockerError = dockerOK, dockerErr - r.SystemdOK, r.SystemdError = systemdOK, systemdErr - r.Workloads = append(append([]Workload{}, containers...), units...) - - markProtected(r.Workloads) - return r -} - -// Hash fingerprints a workload set so an unchanged set never has to be sent. -// -// It sorts first: `docker ps` output ordering is not stable, and an -// ordering-sensitive hash would resend the full list every 60 seconds forever -// — a cost visible only as traffic. -// -// StartedAt is deliberately excluded: it does not change while a container -// runs, and including it would add nothing. Restarts IS included, because a -// container cycling is exactly the change worth reporting. -func Hash(wls []Workload) string { - lines := make([]string, 0, len(wls)) - for _, w := range wls { - lines = append(lines, strings.Join([]string{ - w.Kind, w.ID, w.Name, w.State, w.Health, w.Image, w.Stack, - strconv.Itoa(w.Restarts), - }, "\x00")) - } - sort.Strings(lines) - h := sha256.New() - for _, l := range lines { - h.Write([]byte(l)) - h.Write([]byte("\n")) - } - return hex.EncodeToString(h.Sum(nil)) -} diff --git a/go.work b/go.work index 51f2665..240c5aa 100644 --- a/go.work +++ b/go.work @@ -1,7 +1,6 @@ go 1.26 use ( - ./agent ./server ./vantagectl ) diff --git a/installer/nssm.exe b/installer/nssm.exe deleted file mode 100644 index 6ccfe3cfb85f0f7126648dceb7ac93d58b025a0f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 331264 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 z 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 - if ($out) { Write-Log ("OUT: {0}" -f ($out -join "`n")) } - if ($LASTEXITCODE -ne 0) { - throw ("{0} exited {1}" -f $File, $LASTEXITCODE) - } -} - -function Invoke-NativeSoft { - param([string]$File, [string[]]$Arguments) - Write-Log ("RUN(soft): {0} {1}" -f $File, ($Arguments -join " ")) - # Native stderr merged via 2>&1 becomes terminating errors under - # ErrorActionPreference=Stop; force Continue in this scope so a benign nssm - # message (e.g. "service has not been started") never aborts setup. - $ErrorActionPreference = "Continue" - $out = & $File @Arguments 2>&1 - if ($out) { Write-Log ("OUT: {0}" -f ($out -join "`n")) } - Write-Log ("EXIT: {0}" -f $LASTEXITCODE) -} - -if ($Uninstall) { - try { - Write-Log "=== teardown start ===" - if (-not $InstallDir) { $InstallDir = $PSScriptRoot } - $nssm = Join-Path $InstallDir "nssm.exe" - if (Test-Path $nssm) { - Invoke-NativeSoft -File $nssm -Arguments @("stop", "VantageAgent") - Invoke-NativeSoft -File $nssm -Arguments @("remove", "VantageAgent", "confirm") - } else { - Write-Log "nssm.exe not found at $nssm - using sc.exe fallback" - Invoke-NativeSoft -File "sc.exe" -Arguments @("stop", "VantageAgent") - Invoke-NativeSoft -File "sc.exe" -Arguments @("delete", "VantageAgent") - } - Write-Log "=== teardown ok ===" - exit 0 - } - catch { - Write-Log ("TEARDOWN ERROR: {0}" -f $_.Exception.Message) - # Never block uninstall - exit 0 - } -} - -try { - Write-Log "=== setup start ===" - Write-Log ("ServerId={0} ServerUrl={1} InstallDir={2}" -f $ServerId, $ServerUrl, $InstallDir) - - $cfgDir = Join-Path $env:ProgramData "vantage" - New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null - $cfgPath = Join-Path $cfgDir "config.yaml" - - # Preserve existing config on upgrade. A MajorUpgrade re-runs this script with - # no SERVERID/TOKEN, so blindly rewriting would wipe the agent_token the agent - # persisted after Register(). Only (re)write when a ServerId is supplied - # (fresh install / explicit re-register). - if ((Test-Path $cfgPath) -and (-not $ServerId)) { - Write-Log "config.yaml exists and no ServerId supplied - preserving existing config (upgrade)" - } - else { - $cfg = @" -server_url: "$ServerUrl" -server_id: "$ServerId" -pre_reg_token: "$Token" -agent_token: "" -poll_interval: 30s -tls: true -"@ - Set-Content -Path $cfgPath -Value $cfg -Encoding utf8 - Write-Log "wrote $cfgPath" - - # Lock down ACL: SYSTEM + Administrators only - Invoke-Native -File "icacls" -Arguments @($cfgPath, "/inheritance:r", "/grant:r", "SYSTEM:F", "Administrators:F") - } - - if (-not $InstallDir) { $InstallDir = $PSScriptRoot } - $nssm = Join-Path $InstallDir "nssm.exe" - $exe = Join-Path $InstallDir "vantage-agent.exe" - - if (-not (Test-Path $nssm)) { throw "nssm.exe not found at $nssm" } - if (-not (Test-Path $exe)) { throw "vantage-agent.exe not found at $exe" } - - # Install only if the service isn't already registered (an upgrade may leave - # it in place). "nssm install" on an existing service errors otherwise. - $exists = Get-Service -Name "VantageAgent" -ErrorAction SilentlyContinue - if (-not $exists) { - Invoke-Native -File $nssm -Arguments @("install", "VantageAgent", $exe) - } else { - Write-Log "VantageAgent service already exists - updating binary path" - Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "Application", $exe) - } - Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "Start", "SERVICE_AUTO_START") - - # Redirect service stdout/stderr to log files (nssm discards them otherwise) - # with online rotation at ~1MB. - $outLog = Join-Path $logDir "agent-stdout.log" - $errLog = Join-Path $logDir "agent-stderr.log" - Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStdout", $outLog) - Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStderr", $errLog) - Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStdoutCreationDisposition", "4") - Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStderrCreationDisposition", "4") - Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateFiles", "1") - Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateOnline", "1") - Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateBytes", "1048576") - - # Service is freshly (re)installed and stopped here (teardown removed the old - # one on upgrade), so start it. "restart" would try to stop a not-running - # service and emit a stderr error. - Invoke-NativeSoft -File $nssm -Arguments @("start", "VantageAgent") - - Write-Log "=== setup ok ===" - exit 0 -} -catch { - Write-Log ("ERROR: {0}" -f $_.Exception.Message) - Write-Log ($_.ScriptStackTrace) - exit 1 -} diff --git a/installer/vantage-agent.wxs b/installer/vantage-agent.wxs deleted file mode 100644 index f3d9986..0000000 --- a/installer/vantage-agent.wxs +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 7ea07bd..5484a18 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -740,7 +740,7 @@ case "$ARCH" in esac # Get latest agent release tag -LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/vantage/releases?limit=10" \ +LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/vantage/vantage-agent/releases?limit=10" \ | grep -o '"tag_name":"agent/v[^"]*"' | head -1 | sed 's/"tag_name":"//;s/"//') if [ -z "$LATEST" ]; then @@ -750,8 +750,8 @@ fi VERSION="${LATEST#agent/}" LATEST_ENCODED="${LATEST/\//%%2F}" -BINARY_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}" -CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/checksums.txt" +BINARY_URL="https://${GITEA_HOST}/vantage/vantage-agent/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}" +CHECKSUM_URL="https://${GITEA_HOST}/vantage/vantage-agent/releases/download/${LATEST_ENCODED}/checksums.txt" echo "Updating vantage-agent to ${VERSION} (${ARCH})..." @@ -917,7 +917,7 @@ case "$ARCH" in esac # Get latest agent release tag -LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/vantage/releases?limit=10" \ +LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/vantage/vantage-agent/releases?limit=10" \ | grep -o '"tag_name":"agent/v[^"]*"' | head -1 | sed 's/"tag_name":"//;s/"//') if [ -z "$LATEST" ]; then @@ -927,8 +927,8 @@ fi VERSION="${LATEST#agent/}" LATEST_ENCODED="${LATEST/\//%%2F}" -BINARY_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}" -CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/checksums.txt" +BINARY_URL="https://${GITEA_HOST}/vantage/vantage-agent/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}" +CHECKSUM_URL="https://${GITEA_HOST}/vantage/vantage-agent/releases/download/${LATEST_ENCODED}/checksums.txt" echo "Installing vantage-agent ${VERSION} (${ARCH})..." diff --git a/server/internal/api/install_ps1.go b/server/internal/api/install_ps1.go index 03d8676..e2101b8 100644 --- a/server/internal/api/install_ps1.go +++ b/server/internal/api/install_ps1.go @@ -25,11 +25,11 @@ func handleInstallScriptWindows(c *gin.Context) { "$GiteaHost = \"%s\"\n"+ "$ServerUrl = \"%s\" -replace '^https?://',''\n"+ "\n"+ - "$rel = Invoke-RestMethod -Uri \"https://$GiteaHost/api/v1/repos/mrhid6/vantage/releases?limit=10\"\n"+ + "$rel = Invoke-RestMethod -Uri \"https://$GiteaHost/api/v1/repos/vantage/vantage-agent/releases?limit=10\"\n"+ "$tag = ($rel | Where-Object { $_.tag_name -like 'agent/v*' } | Select-Object -First 1).tag_name\n"+ "if (-not $tag) { throw \"Could not determine latest agent version\" }\n"+ "$enc = $tag -replace '/','%%2F'\n"+ - "$base = \"https://$GiteaHost/mrhid6/vantage/releases/download/$enc\"\n"+ + "$base = \"https://$GiteaHost/vantage/vantage-agent/releases/download/$enc\"\n"+ "\n"+ "$tmp = Join-Path $env:TEMP \"vantage-agent.msi\"\n"+ "Invoke-WebRequest -Uri \"$base/vantage-agent.msi\" -OutFile $tmp\n"+ @@ -56,11 +56,11 @@ func handleUpdateScriptWindows(c *gin.Context) { "\n"+ "$GiteaHost = \"%s\"\n"+ "\n"+ - "$rel = Invoke-RestMethod -Uri \"https://$GiteaHost/api/v1/repos/mrhid6/vantage/releases?limit=10\"\n"+ + "$rel = Invoke-RestMethod -Uri \"https://$GiteaHost/api/v1/repos/vantage/vantage-agent/releases?limit=10\"\n"+ "$tag = ($rel | Where-Object { $_.tag_name -like 'agent/v*' } | Select-Object -First 1).tag_name\n"+ "if (-not $tag) { throw \"Could not determine latest agent version\" }\n"+ "$enc = $tag -replace '/','%%2F'\n"+ - "$base = \"https://$GiteaHost/mrhid6/vantage/releases/download/$enc\"\n"+ + "$base = \"https://$GiteaHost/vantage/vantage-agent/releases/download/$enc\"\n"+ "\n"+ "$tmp = Join-Path $env:TEMP \"vantage-agent.msi\"\n"+ "Invoke-WebRequest -Uri \"$base/vantage-agent.msi\" -OutFile $tmp\n"+ diff --git a/server/internal/services/dispatch.go b/server/internal/services/dispatch.go index 9773942..8656d99 100644 --- a/server/internal/services/dispatch.go +++ b/server/internal/services/dispatch.go @@ -317,7 +317,7 @@ type KeyGenParams struct { func GetLatestAgentVersion() (string, error) { giteaHost := "gitea.hostxtra.co.uk" - url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/vantage/releases?limit=20", giteaHost) + url := fmt.Sprintf("https://%s/api/v1/repos/vantage/vantage-agent/releases?limit=20", giteaHost) resp, err := http.Get(url) if err != nil { return "", fmt.Errorf("fetch releases: %w", err)