10 Commits
Author SHA1 Message Date
mrhid6 6b2c5f6f62 refactor: stand up vantage-agent as its own repository
Agent Release / build (push) Successful in 2m23s
Agent Release / msi (push) Successful in 3m10s
agent/ becomes the repository root, so the module is
gitea.hostxtra.co.uk/vantage/vantage-agent, and installer/ comes with it.

Releases move to this repository, and internal/sync's self-update URLs
move with them. Agents built before this point have the old
mrhid6/vantage path compiled in and will fail the push-button update;
re-running the server-generated /update one-liner on each host moves
them onto a build that knows the new address.
2026-09-08 08:58:30 +00:00
mrhid6 20fd5790a1 refactor: consume vantage-shared as an external private module
shared/ is extracted to gitea.hostxtra.co.uk/vantage/vantage-shared and
pinned at v0.1.0 by server, agent, admin, sitesvc and vantagectl. The
replace directives and the ./shared entry in go.work are gone.

Every Go build now needs a credential for the private module: CI writes a
netrc per job from REGISTRY_USER + RELEASE_TOKEN and sets GOPRIVATE, and
the four Go Dockerfiles take it as a BuildKit secret rather than a build
arg, which would survive in the builder layer's history. RELEASE_TOKEN
needs read access to the vantage org.

admin, sitesvc and vantagectl now build from their own directory; only
server still needs the repository root, for default_steps/. The rebuild
triggers in server-deploy.yml lose their shared/ patterns, since a
service now moves when its own go.mod pin does.
2026-09-08 07:42:58 +00:00
mrhid6 866584de81 refactor: rename shared module to gitea.hostxtra.co.uk/vantage/vantage-shared 2026-09-08 07:34:53 +00:00
mrhid6 1c8e6da013 feat: Move grpc pbs to shared 2026-09-07 15:50:25 +00:00
mrhid6 72e27d9455 feat: Useragent 2026-08-24 12:17:46 +00:00
mrhid6 f2053cf703 fix: Word and colour Windows workloads correctly across the UI 2026-08-13 12:57:37 +00:00
mrhid6 a5930e89bd fix: Trim Windows event log after SCM filtering, not before 2026-08-13 12:12:11 +00:00
mrhid6 2a3ab12396 feat: Control Windows services and read their event log as workloads 2026-08-13 12:04:49 +00:00
mrhid6 c38e2ab9ba fix: Respect .exe word boundary and unterminated quotes in servicePath 2026-08-13 12:00:59 +00:00
mrhid6 d764eb2c6f feat: Collect Windows services as workloads 2026-08-13 11:52:08 +00:00
28 changed files with 990 additions and 627 deletions
+142
View File
@@ -0,0 +1,142 @@
name: Agent Release
# The tag stays "agent/v*" rather than becoming a bare "v*", even though this
# repository is only the agent now. The tag is not a private detail: it is the
# release the fleet downloads by, it is what UpdateAgentCmd carries, and the
# control plane finds the newest one by grepping tag names for that exact
# prefix. Renaming it would be a second breaking change stacked on the
# repository move.
on:
push:
tags:
- "agent/v*"
jobs:
build:
runs-on: ubuntu-docker
container: node:26
env:
GOPRIVATE: gitea.hostxtra.co.uk/*
steps:
- name: Checkout
uses: actions/checkout@v4
# vantage-shared is a private module, so the Go builds below cannot
# resolve it without a credential.
- name: Write the module fetch credential
run: |
umask 077
printf 'machine gitea.hostxtra.co.uk\nlogin %s\npassword %s\n' \
"${{ secrets.REGISTRY_USER }}" "${{ secrets.RELEASE_TOKEN }}" \
> "$HOME/.netrc"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
cache-dependency-path: go.sum
- name: Extract version
id: version
run: echo "VERSION=${GITHUB_REF_NAME#agent/}" >> $GITHUB_OUTPUT
- name: Build
env:
VERSION: ${{ steps.version.outputs.VERSION }}
run: |
mkdir -p dist
GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/vantage-agent-linux-amd64 ./cmd
GOOS=linux GOARCH=arm64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/vantage-agent-linux-arm64 ./cmd
GOOS=windows GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/vantage-agent-windows-amd64.exe ./cmd
- name: Checksums
working-directory: dist
run: sha256sum vantage-agent-linux-amd64 vantage-agent-linux-arm64 vantage-agent-windows-amd64.exe > checksums.txt
- name: Create release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
token: ${{ secrets.RELEASE_TOKEN }}
files: |
dist/vantage-agent-linux-amd64
dist/vantage-agent-linux-arm64
dist/vantage-agent-windows-amd64.exe
dist/checksums.txt
msi:
needs: build
runs-on: windows-2022
env:
GOPRIVATE: gitea.hostxtra.co.uk/*
steps:
- name: Checkout
uses: actions/checkout@v4
# Same private-module credential as the build job, in the file
# Windows Go looks for: _netrc in the profile directory, not .netrc.
- name: Write the module fetch credential
shell: pwsh
run: |
"machine gitea.hostxtra.co.uk`nlogin ${{ secrets.REGISTRY_USER }}`npassword ${{ secrets.RELEASE_TOKEN }}" |
Out-File -Encoding ascii "$env:USERPROFILE\_netrc"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
cache-dependency-path: go.sum
- name: Extract version
id: version
shell: pwsh
run: |
$v = "${{ github.ref_name }}" -replace '^agent/v', ''
"VERSION=$v" | Out-File -Append $env:GITHUB_OUTPUT
# MSI ProductVersion must be numeric x.x.x.x
"MSIVERSION=$v.0" | Out-File -Append $env:GITHUB_OUTPUT
- name: Build agent exe
shell: pwsh
env:
VERSION: ${{ steps.version.outputs.VERSION }}
run: |
$env:GOOS = "windows"; $env:GOARCH = "amd64"
go build -ldflags="-s -w -X main.Version=$env:VERSION" -o installer/vantage-agent-windows-amd64.exe ./cmd
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.*
- name: Build MSI
working-directory: installer
shell: pwsh
run: |
$env:PATH = "$env:PATH;$env:USERPROFILE\.dotnet\tools"
wix build vantage-agent.wxs -d Version=${{ steps.version.outputs.MSIVERSION }} -o vantage-agent.msi
(Get-FileHash vantage-agent.msi -Algorithm SHA256).Hash.ToLower() + " vantage-agent.msi" | Out-File -Encoding ascii checksums-msi.txt
- name: Attach MSI to release
working-directory: installer
shell: pwsh
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
$api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$tag = [uri]::EscapeDataString("${{ github.ref_name }}")
$headers = @{ Authorization = "token $env:TOKEN" }
# gitea-release-action can't find a slashed tag, so append via the API directly
$rel = Invoke-RestMethod -Headers $headers -Uri "$api/releases/tags/$tag"
foreach ($f in "vantage-agent.msi", "checksums-msi.txt") {
$name = [uri]::EscapeDataString($f)
Invoke-RestMethod -Headers $headers -Method Post -InFile $f `
-ContentType "application/octet-stream" `
-Uri "$api/releases/$($rel.id)/assets?name=$name"
}
+5
View File
@@ -0,0 +1,5 @@
dist
installer/vantage-agent-windows-amd64.exe
installer/vantage-agent.msi
installer/checksums-msi.txt
.env
+137
View File
@@ -0,0 +1,137 @@
# Vantage agent (`vantage-agent`)
The lightweight Go agent installed on every managed server, and the Windows
installer that packages it. Extracted from the `vantage` monorepo with its
history; the agent is the repository root, so the module is
`gitea.hostxtra.co.uk/vantage/vantage-agent`.
```
vantage-agent/
├── cmd/main.go # flags: -generate-key
├── internal/
│ ├── checker/ # monitor check execution
│ ├── config/ # config.yaml load/save
│ ├── exec/ # workflow step execution
│ ├── grpc/ # client + the codec registration
│ ├── inventory/ # CPU/mem/disk collection (linux/other/windows)
│ ├── keys/ # authorized_keys read/diff/write
│ ├── monitors/ # agent-run monitor loop
│ ├── proxy/ # console relay, always from 127.0.0.1
│ ├── sync/ # poll loop + command stream + self-update
│ ├── updates/ # OS package update check/apply
│ ├── winexec/ # PowerShell invocation on Windows
│ └── workloads/ # containers and units/services
├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs
└── .gitea/workflows/agent-release.yml
```
## Relationship to the other repositories
| Repository | Relationship |
| ---------------- | ------------------------------------------------------------------------------------------------------ |
| `vantage-shared` | a private Go module. `grpc/pb` and `grpc/codec` are the wire contract; `proto/` there documents them |
| `vantage` | the control plane. **No import in either direction** — the coupling is the gRPC wire, and it is entirely mediated by `vantage-shared` |
**`vantage-shared` is private**, so every Go build needs
`GOPRIVATE=gitea.hostxtra.co.uk/*` plus a credential. CI writes a netrc from
`REGISTRY_USER` + `RELEASE_TOKEN` (**that token needs read access to the
`vantage` org**) — twice, because the `msi` job is Windows and Go looks for
`_netrc` in the profile directory there, not `.netrc`. Locally, either a netrc
or `git config --global url."git@gitea.hostxtra.co.uk:".insteadOf https://gitea.hostxtra.co.uk/`.
### A wire change is three steps, in order
`shared/grpc/pb` is hand-written and shared by both sides, so a new message is a
compile error rather than a silent disagreement — but only once each side moves:
1. release `vantage-shared` (and change `proto/vantage/v1/vantage.proto` in the
same commit as the Go types)
2. bump the pin in `vantage`'s `server/go.mod` — live at the next push to main
3. bump the pin here — live only at the next `agent/v*` tag
The control plane runs ahead of the fleet in between. That was true before the
split too; it is now explicit in two `go.mod` files rather than implicit in a
shared directory.
## Releases
Tags are `agent/v*`, **not** bare `v*`, even though this repository is only the
agent. The tag is not a private detail: it is what the fleet downloads by, what
`UpdateAgentCmd` carries, and what the control plane greps release tag names for
when answering `GET /api/agent/latest-version`. Renaming it would be a second
breaking change stacked on the repository move.
```bash
git tag agent/v1.2.0 && git push origin agent/v1.2.0
```
Builds `linux/amd64`, `linux/arm64` and `windows/amd64`, writes `checksums.txt`,
creates the Gitea release. A second `msi` job on `windows-2022` builds the exe
again, packages it with WiX and appends the MSI to the same release **through
the API** — `gitea-release-action` cannot find a tag with a slash in it.
### The self-update path, and what the move broke
`internal/sync` downloads its own replacement from
`<gitea>/vantage/vantage-agent/releases/download/<tag>/…`, verifying the
SHA-256 from `checksums.txt` before swapping itself. That path is **compiled
into the binary**.
**Every agent built before this move has the old path — `mrhid6/vantage`
baked in, and releases are no longer published there.** For those agents the
push-button update in the UI will fail: the download 404s. They are not
stranded, because `/update` and `/update.ps1` are generated by the control plane
at request time and point wherever the current server says, so re-running the
update one-liner on a host moves it onto a build that knows the new address.
After that, self-update works again permanently.
This was a deliberate choice — the alternative was publishing releases to a
repository that no longer holds the source — but it means **the fleet needs one
pass of the update one-liner**, and the control plane must be redeployed with
the new release paths *first*, or the one-liner points at the old repository
too.
## What the agent will not do
- **It never reboots a host.** `ApplyUpdatesCmd` installs and stops there;
`inventory.reboot_required` reports that one is owed.
- **It decides what it will not touch.** The protected workload set is computed
and enforced agent-side — `vantage-agent.service`, `VantageAgent` on Windows,
and its own container ID from `/proc/self/cgroup`. The control plane may name
a target; the agent decides what it will do to itself. A server-side denylist
alone would be bypassed by the next dispatch path someone adds, and the
failure is unrecoverable from the UI.
- **The console relay dials `127.0.0.1` only.** The host is hardcoded here, so
the control plane can name a port and nothing else.
- **No `authorized_keys` management on Windows**, and no package inventory: a
Windows agent never calls `ReportPackages`, so no `server_packages` document
exists for it at all — a different, earlier state than the `unsupported` a
Linux distribution reaches when its family has no security feed.
## Platform split
Windows support is build tags, not runtime branches — `systemd_linux.go` /
`services_windows.go` and the matching `control_` and `logs_` pairs. Windows
collection runs PowerShell through `internal/winexec`, and **every script that
reports data emits JSON that a build-tag-free parser reads**, so those parsers
are tested on Linux. This module has no Windows CI: the control verbs and
`serviceDisplayName` emit no JSON, have no parser, and are exercised only by
running the agent on Windows.
Windows updates go through the Windows Update COM API
(`Microsoft.Update.Session`) rather than the PSWindowsUpdate module, which would
need a PowerShell Gallery install on every host and fails on an air-gapped
fleet. `CurrentVersion` is empty on Windows and `NewVersion` carries the KB
article ID: a Windows update is not a version bump of a named package.
## Two constants that mirror the control plane
Neither can be shared — this is a separate module and the control plane's are
under `internal/` — so both must change in step, by hand:
- the workload log cap, **500 lines and 256KB whichever binds first**, mirrored
in the control plane's `services.MaxWorkloadLogLines`
- `streamHealthyAfter` and the 70s command-stream watchdog, which pair with the
control plane's 20s `PingCmd`. The watchdog arms only **after** a first ping
has been seen, so an older server that sends none is treated as working rather
than put into a reconnect loop.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"os/signal"
"syscall"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
agentsync "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/sync"
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config"
agentsync "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/sync"
)
var Version = "dev"
+5 -4
View File
@@ -1,16 +1,17 @@
module gitea.hostxtra.co.uk/mrhid6/vantage/agent
module gitea.hostxtra.co.uk/vantage/vantage-agent
go 1.26
require (
golang.org/x/sys v0.20.0
golang.org/x/sys v0.47.0
google.golang.org/grpc v1.64.0
gopkg.in/yaml.v3 v3.0.1
)
require (
golang.org/x/net v0.25.0 // indirect
golang.org/x/text v0.15.0 // indirect
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
)
+8 -6
View File
@@ -1,11 +1,13 @@
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.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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=
+5
View File
@@ -18,6 +18,10 @@ const (
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)"
)
@@ -84,6 +88,7 @@ func runHTTP(ctx context.Context, s Spec) Result {
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()}
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"sync"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
)
type streamWriter struct {
+3 -2
View File
@@ -6,7 +6,8 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"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"
@@ -15,7 +16,7 @@ import (
)
func init() {
encoding.RegisterCodec(JSONCodec{})
encoding.RegisterCodec(codec.JSONCodec{})
}
type Client struct {
-17
View File
@@ -1,17 +0,0 @@
package grpcclient
import "encoding/json"
type JSONCodec struct{}
func (JSONCodec) Marshal(v interface{}) ([]byte, error) {
return json.Marshal(v)
}
func (JSONCodec) Unmarshal(data []byte, v interface{}) error {
return json.Unmarshal(data, v)
}
func (JSONCodec) Name() string {
return "proto"
}
-480
View File
@@ -1,480 +0,0 @@
package pb
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type RegisterRequest struct {
ServerId string `json:"server_id"`
PreRegToken string `json:"pre_reg_token"`
Hostname string `json:"hostname"`
IpAddress string `json:"ip_address"`
OsInfo string `json:"os_info"`
}
type RegisterResponse struct {
AgentToken string `json:"agent_token"`
}
type SyncRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
AgentVersion string `json:"agent_version,omitempty"`
}
type SyncResponse struct {
PublicKeys []string `json:"public_keys"`
// CollectPackages tells the agent whether this instance's licence grants
// vulnerability scanning. Absent decodes as false, which is the safe
// direction: an older server leaves agents collecting nothing.
CollectPackages bool `json:"collect_packages,omitempty"`
}
type OSRelease struct {
Family string `json:"family"`
// VersionId is not optional: Ubuntu 22.04 and 24.04 publish different fixed
// versions for the same CVE, so a scan without it is guesswork.
VersionId string `json:"version_id"`
Arch string `json:"arch,omitempty"`
}
type InstalledPackage struct {
Name string `json:"name"`
Version string `json:"version"`
Epoch int32 `json:"epoch,omitempty"`
Arch string `json:"arch,omitempty"`
// SourceName is what the Debian and Ubuntu feeds are keyed on: one advisory
// against "openssl" covers libssl3, openssl and libssl-dev.
SourceName string `json:"source_name,omitempty"`
}
// ReportPackagesRequest carries a server's installed package set.
//
// The agent calls twice at most: first with Packages empty, offering only the
// hash. If the server already holds it, NeedFull is false and the ~150KB body
// is never sent.
type ReportPackagesRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Hash string `json:"hash"`
Os OSRelease `json:"os"`
Packages []InstalledPackage `json:"packages,omitempty"`
}
type ReportPackagesResponse struct {
NeedFull bool `json:"need_full"`
}
type UploadKeyRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
PublicKey string `json:"public_key"`
Label string `json:"label"`
PrivateKey string `json:"private_key,omitempty"`
}
type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
type PackageUpdate struct {
Name string `json:"name"`
CurrentVersion string `json:"current_version,omitempty"`
NewVersion string `json:"new_version"`
}
type ReportUpdatesRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Updates []PackageUpdate `json:"updates"`
}
type ReportUpdatesResponse struct{}
type CPUReport struct {
Model string `json:"model,omitempty"`
Cores int `json:"cores,omitempty"`
UsagePct float64 `json:"usage_pct"`
Load1 float64 `json:"load1,omitempty"`
}
type MemReport struct {
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
}
type PartitionReport struct {
Device string `json:"device"`
Mountpoint string `json:"mountpoint"`
Fstype string `json:"fstype,omitempty"`
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
}
type InventoryReport struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
IncludeStatic bool `json:"include_static"`
CPU *CPUReport `json:"cpu,omitempty"`
Memory *MemReport `json:"memory,omitempty"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
RebootRequired bool `json:"reboot_required,omitempty"`
}
type InventoryReportResponse struct{}
type MonitorSpec struct {
MonitorId string `json:"monitor_id"`
Type string `json:"type"`
URL string `json:"url,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Method string `json:"method,omitempty"`
ExpectedStatus int `json:"expected_status,omitempty"`
Keyword string `json:"keyword,omitempty"`
TLSWarnDays int `json:"tls_warn_days,omitempty"`
Insecure bool `json:"insecure,omitempty"`
IntervalSec int `json:"interval_sec"`
Retries int `json:"retries"`
}
type SyncMonitorsRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
}
type SyncMonitorsResponse struct {
Monitors []MonitorSpec `json:"monitors,omitempty"`
}
type CheckResult struct {
MonitorId string `json:"monitor_id"`
Up bool `json:"up"`
LatencyMs int `json:"latency_ms"`
Message string `json:"message,omitempty"`
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
}
type ReportChecksRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Results []CheckResult `json:"results,omitempty"`
}
type ReportChecksResponse struct{}
type ApplyUpdatesCmd struct{}
type OpenProxyCmd struct {
ProxyId string `json:"proxy_id"`
Port uint32 `json:"port"`
}
type ProxyOpen struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
ProxyId string `json:"proxy_id"`
}
type ProxyClose struct {
Reason string `json:"reason,omitempty"`
}
type ProxyClientMsg struct {
Open *ProxyOpen `json:"open,omitempty"`
Data []byte `json:"data,omitempty"`
Close *ProxyClose `json:"close,omitempty"`
}
type ProxyServerMsg struct {
Data []byte `json:"data,omitempty"`
Close *ProxyClose `json:"close,omitempty"`
}
type ServerCommand struct {
CommandId string `json:"command_id"`
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
RunStep *RunStepCmd `json:"run_step,omitempty"`
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
OpenProxy *OpenProxyCmd `json:"open_proxy,omitempty"`
Ping *PingCmd `json:"ping,omitempty"`
RefreshWorkloads *RefreshWorkloadsCmd `json:"refresh_workloads,omitempty"`
ControlWorkload *ControlWorkloadCmd `json:"control_workload,omitempty"`
WorkloadLogs *WorkloadLogsCmd `json:"workload_logs,omitempty"`
}
// PingCmd is a server-originated liveness beat. It carries nothing and expects
// no reply: its arrival is the entire message. See the .proto for why gRPC
// keepalive is not sufficient on its own.
type PingCmd struct{}
type CleanupWorkspaceCmd struct {
WorkspaceId string `json:"workspace_id"`
}
type DeleteKeyCmd struct {
Label string `json:"label"`
}
type UpdateAgentCmd struct {
Version string `json:"version"`
GiteaBaseURL string `json:"gitea_base_url"`
}
type GenerateKeyCmd struct {
Label string `json:"label"`
KeyType string `json:"key_type,omitempty"`
KeySize int `json:"key_size,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
Comment string `json:"comment,omitempty"`
}
type AgentMessage struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Ready *AgentReady `json:"ready,omitempty"`
Result *CommandResult `json:"result,omitempty"`
StepResult *StepResult `json:"step_result,omitempty"`
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
WorkloadLogsResult *WorkloadLogsResult `json:"workload_logs_result,omitempty"`
}
type AgentReady struct{}
type CommandResult struct {
CommandId string `json:"command_id"`
Success bool `json:"success"`
Message string `json:"message"`
}
type RunStepCmd struct {
Interpreter string `json:"interpreter"`
Script string `json:"script"`
Env map[string]string `json:"env,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
WorkspaceId string `json:"workspace_id,omitempty"`
}
type StepResult struct {
CommandId string `json:"command_id"`
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
OutputEnv map[string]string `json:"output_env,omitempty"`
}
type StepOutputChunk struct {
CommandId string `json:"command_id"`
Seq uint64 `json:"seq"`
Data []byte `json:"data,omitempty"`
Eof bool `json:"eof,omitempty"`
}
type Vantage_CommandStreamClient interface {
Send(*AgentMessage) error
Recv() (*ServerCommand, error)
grpc.ClientStream
}
type vantageCommandStreamClient struct {
grpc.ClientStream
}
func (c *vantageCommandStreamClient) Send(m *AgentMessage) error {
return c.ClientStream.SendMsg(m)
}
func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
m := new(ServerCommand)
if err := c.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type Vantage_CommandStreamServer interface {
Send(*ServerCommand) error
Recv() (*AgentMessage, error)
grpc.ServerStream
}
type keyManagerCommandStreamServer struct {
grpc.ServerStream
}
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
return s.ServerStream.SendMsg(m)
}
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
m := new(AgentMessage)
if err := s.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type Vantage_ProxyStreamServer interface {
Send(*ProxyServerMsg) error
Recv() (*ProxyClientMsg, error)
grpc.ServerStream
}
type vantageProxyStreamServer struct {
grpc.ServerStream
}
func (s *vantageProxyStreamServer) Send(m *ProxyServerMsg) error {
return s.ServerStream.SendMsg(m)
}
func (s *vantageProxyStreamServer) Recv() (*ProxyClientMsg, error) {
m := new(ProxyClientMsg)
if err := s.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type Vantage_ProxyStreamClient interface {
Send(*ProxyClientMsg) error
Recv() (*ProxyServerMsg, error)
CloseSend() error
grpc.ClientStream
}
type vantageProxyStreamClient struct {
grpc.ClientStream
}
func (c *vantageProxyStreamClient) Send(m *ProxyClientMsg) error {
return c.ClientStream.SendMsg(m)
}
func (c *vantageProxyStreamClient) Recv() (*ProxyServerMsg, error) {
m := new(ProxyServerMsg)
if err := c.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type VantageClient interface {
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error)
ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error)
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error)
}
type UnimplementedVantageServer struct{}
func (UnimplementedVantageServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
func (UnimplementedVantageServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
func (UnimplementedVantageServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
type keyManagerClient struct {
cc grpc.ClientConnInterface
}
func NewVantageClient(cc grpc.ClientConnInterface) VantageClient {
return &keyManagerClient{cc}
}
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
out := new(RegisterResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/Register", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
out := new(SyncResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncKeys", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
out := new(UploadKeyResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/UploadGeneratedKey", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error) {
out := new(ReportUpdatesResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportUpdates", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) ReportPackages(ctx context.Context, in *ReportPackagesRequest, opts ...grpc.CallOption) (*ReportPackagesResponse, error) {
out := new(ReportPackagesResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportPackages", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
out := new(InventoryReportResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error) {
out := new(SyncMonitorsResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncMonitors", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error) {
out := new(ReportChecksResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportChecks", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
desc := &grpc.StreamDesc{StreamName: "CommandStream", ServerStreams: true, ClientStreams: true}
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/CommandStream", opts...)
if err != nil {
return nil, err
}
return &vantageCommandStreamClient{stream}, nil
}
func (c *keyManagerClient) ProxyStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_ProxyStreamClient, error) {
desc := &grpc.StreamDesc{StreamName: "ProxyStream", ServerStreams: true, ClientStreams: true}
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/ProxyStream", opts...)
if err != nil {
return nil, err
}
return &vantageProxyStreamClient{stream}, nil
}
-80
View File
@@ -1,80 +0,0 @@
package pb
import (
"context"
"google.golang.org/grpc"
)
// Workload registry messages. Hand-written like the rest of this package: the
// .proto is the contract, this file is the Go side of it, and the two must be
// changed together.
// Workload is one container or one systemd unit.
type Workload struct {
Kind string `json:"kind"`
Id string `json:"id"`
Name string `json:"name"`
State string `json:"state"`
Health string `json:"health,omitempty"`
Image string `json:"image,omitempty"`
Stack string `json:"stack,omitempty"`
Ports []string `json:"ports,omitempty"`
Restarts int32 `json:"restarts,omitempty"`
StartedAt string `json:"started_at,omitempty"` // RFC3339, empty when not running
Protected bool `json:"protected,omitempty"`
}
// ReportWorkloadsRequest carries what a server is running.
//
// Offer-then-send, the same handshake as ReportPackages: the agent calls once
// with Workloads empty, and resends with the body only if NeedFull is set.
type ReportWorkloadsRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Hash string `json:"hash"`
DockerOk bool `json:"docker_ok"`
DockerError string `json:"docker_error,omitempty"`
SystemdOk bool `json:"systemd_ok"`
SystemdError string `json:"systemd_error,omitempty"`
Workloads []Workload `json:"workloads,omitempty"` // empty on the offer call
// Full marks the second call. It is not inferred from an empty Workloads
// slice: a host running nothing sends an empty list as its full report.
Full bool `json:"full,omitempty"`
}
type ReportWorkloadsResponse struct {
NeedFull bool `json:"need_full"`
}
// RefreshWorkloadsCmd carries no payload back. It makes the agent report
// immediately through ReportWorkloads, so there is exactly one writer for the
// server_workloads collection rather than two arriving by different routes.
type RefreshWorkloadsCmd struct{}
type ControlWorkloadCmd struct {
Kind string `json:"kind"`
Id string `json:"id"`
Action string `json:"action"` // start | stop | restart
}
type WorkloadLogsCmd struct {
Kind string `json:"kind"`
Id string `json:"id"`
Tail int32 `json:"tail,omitempty"`
}
type WorkloadLogsResult struct {
CommandId string `json:"command_id"`
Text string `json:"text,omitempty"`
Truncated bool `json:"truncated,omitempty"`
Error string `json:"error,omitempty"`
}
func (c *keyManagerClient) ReportWorkloads(ctx context.Context, in *ReportWorkloadsRequest, opts ...grpc.CallOption) (*ReportWorkloadsResponse, error) {
out := new(ReportWorkloadsResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportWorkloads", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"syscall"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
)
func collect(r *pb.InventoryReport, includeStatic bool) {
+1 -2
View File
@@ -7,7 +7,6 @@
// without it this file compiles on Linux too and collides with collect_linux.go.
package inventory
import "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
import "gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
func collect(r *pb.InventoryReport, includeStatic bool) {}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
)
var (
+1 -1
View File
@@ -1,6 +1,6 @@
package inventory
import "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
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{}}
+4 -4
View File
@@ -6,10 +6,10 @@ import (
"sync"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/checker"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/checker"
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config"
grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc"
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
)
const syncInterval = 30 * time.Second
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"strconv"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
)
const (
+4 -4
View File
@@ -8,10 +8,10 @@ import (
"sync/atomic"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/packages"
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config"
grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc"
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/packages"
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
)
// collectPackagesFlag is written by the 30s key poll and read by the hourly
+13 -13
View File
@@ -17,15 +17,15 @@ import (
"sync"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
agentexec "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/exec"
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/inventory"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/keys"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/monitors"
agentproxy "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/proxy"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/updates"
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config"
agentexec "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/exec"
grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc"
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/inventory"
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/keys"
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/monitors"
agentproxy "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/proxy"
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/updates"
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
)
func Run(ctx context.Context, cfg *config.Config, version string) error {
@@ -554,8 +554,8 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
u := cmd.UpdateAgent
arch := runtime.GOARCH
tag := "agent%2Fv" + u.Version
binaryURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch)
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
binaryURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch)
checksumURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
@@ -592,8 +592,8 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
u := cmd.UpdateAgent
tag := "agent%2Fv" + u.Version
msiURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent.msi", u.GiteaBaseURL, tag)
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums-msi.txt", u.GiteaBaseURL, tag)
msiURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/vantage-agent.msi", u.GiteaBaseURL, tag)
checksumURL := fmt.Sprintf("%s/vantage/vantage-agent/releases/download/%s/checksums-msi.txt", u.GiteaBaseURL, tag)
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
+4 -4
View File
@@ -5,10 +5,10 @@ import (
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config"
grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/workloads"
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/config"
grpcclient "gitea.hostxtra.co.uk/vantage/vantage-agent/internal/grpc"
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/workloads"
"gitea.hostxtra.co.uk/vantage/vantage-shared/grpc/pb"
)
// workloadInterval is the report cadence. Sixty seconds is affordable because
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/winexec"
"gitea.hostxtra.co.uk/vantage/vantage-agent/internal/winexec"
)
const (
+7 -3
View File
@@ -19,12 +19,16 @@ func Run(ctx context.Context, script string) (string, error) {
out, err := cmd.Output()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr)))
}
// 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
+74
View File
@@ -0,0 +1,74 @@
package workloads
import (
"context"
"fmt"
"os/exec"
"strings"
"gitea.hostxtra.co.uk/vantage/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)
}
}
+89
View File
@@ -0,0 +1,89 @@
package workloads
import (
"context"
"fmt"
"os/exec"
"strconv"
"gitea.hostxtra.co.uk/vantage/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)
}
+50
View File
@@ -0,0 +1,50 @@
package workloads
import (
"context"
"os"
"time"
"gitea.hostxtra.co.uk/vantage/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, ""
}
+224
View File
@@ -0,0 +1,224 @@
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
@@ -0,0 +1,207 @@
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)
}
}