refactor: move the agent and its installer to vantage-agent

agent/ becomes the root of gitea.hostxtra.co.uk/vantage/vantage-agent,
with installer/ alongside it, and agent-release.yml goes with them.

Releases now come from that repository, so the six places this server
generates or reads a release URL are repointed: both install scripts,
both update scripts, and the latest-version lookup in dispatch.go. The
agent/v* tag prefix is unchanged — those scripts grep for it.

Agents built before this move have the old mrhid6/vantage path compiled
into their self-update and will 404 on the push-button update. The
remedy is the /update one-liner, which this server generates and which
therefore has to ship first.
This commit is contained in:
2026-09-08 09:00:22 +00:00
parent 1c6d9e8495
commit 44d9036440
51 changed files with 62 additions and 5129 deletions
-138
View File
@@ -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"
}
+51 -44
View File
@@ -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
`<gitea>/vantage/vantage-agent/releases/download/<tag>/…`.
**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 |
-39
View File
@@ -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)
}
}
-17
View File
@@ -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
)
-20
View File
@@ -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=
-227
View File
@@ -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)
}
-59
View File
@@ -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)
}
-147
View File
@@ -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
}
-189
View File
@@ -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)
}
-158
View File
@@ -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
}
-12
View File
@@ -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) {}
-183
View File
@@ -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
}
-9
View File
@@ -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
}
-228
View File
@@ -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
}
-158
View File
@@ -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()
}
}
}
-63
View File
@@ -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)
}
-73
View File
@@ -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
}
-159
View File
@@ -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 ("<version>-r<rev>"), 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))
}
-114
View File
@@ -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
}
-125
View File
@@ -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))
}
-808
View File
@@ -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
}
-152
View File
@@ -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,
})
}
-24
View File
@@ -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() }
-252
View File
@@ -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, "-")
}
-9
View File
@@ -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 }
-117
View File
@@ -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"
}
-48
View File
@@ -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
}
-69
View File
@@ -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)
}
}
-24
View File
@@ -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)
}
-20
View File
@@ -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)
}
}
-35
View File
@@ -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
}
-64
View File
@@ -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)
}
-56
View File
@@ -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
}
@@ -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)
}
}
-141
View File
@@ -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()
}
-66
View File
@@ -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
}
-30
View File
@@ -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
}
-89
View File
@@ -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)
}
@@ -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, ""
}
-94
View File
@@ -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
}
-22
View File
@@ -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")
}
-224
View File
@@ -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: "<timestamp> <level> <message>", 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 ""
}
-207
View File
@@ -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)
}
}
-60
View File
@@ -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))
}
-1
View File
@@ -1,7 +1,6 @@
go 1.26
use (
./agent
./server
./vantagectl
)
Binary file not shown.
-138
View File
@@ -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
}
-82
View File
@@ -1,82 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<?ifndef Version ?>
<?define Version = "0.0.0.0" ?>
<?endif?>
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package Name="Vantage Agent" Manufacturer="Vantage"
Version="$(var.Version)" UpgradeCode="7d1e6d2c-2a5f-4b3e-9c3a-8a1b2c3d4e5f"
Scope="perMachine">
<MajorUpgrade DowngradeErrorMessage="A newer version is already installed."
Schedule="afterInstallInitialize" />
<MediaTemplate EmbedCab="yes" />
<!-- Public properties settable via msiexec: SERVERID, TOKEN, SERVERURL -->
<Property Id="SERVERID" Secure="yes" />
<Property Id="TOKEN" Secure="yes" />
<Property Id="SERVERURL" Secure="yes" />
<StandardDirectory Id="ProgramFiles64Folder">
<Directory Id="INSTALLDIR" Name="Vantage">
<Component Id="AgentExe" Guid="*">
<File Id="AgentExe" Source="vantage-agent-windows-amd64.exe" Name="vantage-agent.exe" KeyPath="yes" />
</Component>
<Component Id="NssmExe" Guid="*">
<File Id="NssmExe" Source="nssm.exe" Name="nssm.exe" KeyPath="yes" />
</Component>
<Component Id="SetupScript" Guid="*">
<File Id="SetupScript" Source="setup.ps1" Name="setup.ps1" KeyPath="yes" />
</Component>
</Directory>
</StandardDirectory>
<Feature Id="Main">
<ComponentRef Id="AgentExe" />
<ComponentRef Id="NssmExe" />
<ComponentRef Id="SetupScript" />
</Feature>
<!-- Write config.yaml, then install + start the service via nssm.
Implemented as sequenced CustomActions running a helper script.
Deferred CustomActions run out-of-process (and with Impersonate="no",
as SYSTEM) with NO access to the installer property table, so
"[SERVERID]"/"[TOKEN]"/"[SERVERURL]"/"[INSTALLDIR]" would resolve to
empty strings if referenced directly on the deferred action. The fix
is the standard CustomActionData marshaling pattern: an immediate
SetProperty (type 51) with the SAME Id as the deferred CustomAction
runs first (while property values are still visible) and resolves
the formatted string; the deferred Directory/ExeCommand CustomAction
that shares that Id then receives the resolved string back as its
CustomActionData, referenced here as "[WriteConfig]". This avoids
pulling in the WixToolset.Util extension (WixQuietExec64) purely to
get CustomActionData plumbing.
NOTE: this only builds/validates the MSI's XML in CI - it has not
been verified with a real install on Windows. Needs a smoke test
(msiexec /i, confirm C:\ProgramData\Vantage\config.yaml or similar
is written with the correct values, and the service starts) on an
actual Windows machine before this is trusted in production. -->
<SetProperty Id="WriteConfig"
Before="WriteConfig" Sequence="execute" Condition="NOT Installed"
Value='cmd.exe /c powershell -ExecutionPolicy Bypass -File "[INSTALLDIR]setup.ps1" -ServerId "[SERVERID]" -Token "[TOKEN]" -ServerUrl "[SERVERURL]"' />
<CustomAction Id="WriteConfig" Directory="INSTALLDIR" ExeCommand="[WriteConfig]"
Execute="deferred" Impersonate="no" Return="check" />
<!-- Teardown on uninstall: stop + remove the service BEFORE RemoveFiles
deletes nssm.exe/setup.ps1. Same CustomActionData marshaling pattern
as WriteConfig. REMOVE="ALL" = full uninstall (not a component-level
repair/modify). -->
<SetProperty Id="RemoveService"
Before="RemoveService" Sequence="execute" Condition="REMOVE=&quot;ALL&quot;"
Value='cmd.exe /c powershell -ExecutionPolicy Bypass -File "[INSTALLDIR]setup.ps1" -Uninstall' />
<CustomAction Id="RemoveService" Directory="INSTALLDIR" ExeCommand="[RemoveService]"
Execute="deferred" Impersonate="no" Return="ignore" />
<InstallExecuteSequence>
<Custom Action="WriteConfig" After="InstallFiles" Condition="NOT Installed" />
<Custom Action="RemoveService" Before="RemoveFiles" Condition="REMOVE=&quot;ALL&quot;" />
</InstallExecuteSequence>
</Package>
</Wix>
+6 -6
View File
@@ -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})..."
+4 -4
View File
@@ -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"+
+1 -1
View File
@@ -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)