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 6ccfe3c..0000000 Binary files a/installer/nssm.exe and /dev/null differ diff --git a/installer/setup.ps1 b/installer/setup.ps1 deleted file mode 100644 index 5ba6c2e..0000000 --- a/installer/setup.ps1 +++ /dev/null @@ -1,138 +0,0 @@ -param( - [string]$ServerId, - [string]$Token, - [string]$ServerUrl, - [string]$InstallDir, - [switch]$Uninstall -) - -$ErrorActionPreference = "Stop" -$logDir = Join-Path $env:ProgramData "vantage" -New-Item -ItemType Directory -Force -Path $logDir | Out-Null -$log = Join-Path $logDir "install.log" - -function Write-Log($msg) { - $line = "{0} {1}" -f (Get-Date -Format "s"), $msg - Add-Content -Path $log -Value $line -} - -# Fail native-exe (nssm) calls loudly: check $LASTEXITCODE after each call -function Invoke-Native { - param([string]$File, [string[]]$Arguments) - Write-Log ("RUN: {0} {1}" -f $File, ($Arguments -join " ")) - $out = & $File @Arguments 2>&1 - 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)