Compare commits

...
20 Commits
Author SHA1 Message Date
mrhid6 4ff8fc8d51 docs: document the workload registry
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 8m45s
Agent Release / build (push) Successful in 10m54s
Agent Release / msi (push) Successful in 2m31s
2026-08-07 09:09:18 +01:00
mrhid6 483053b9a2 feat: workload registry UI 2026-08-07 09:06:36 +01:00
mrhid6 fd4c51f3db feat: workload registry REST API 2026-08-07 09:01:46 +01:00
mrhid6 1b351cfca4 feat: agent reports workloads and handles workload commands 2026-08-07 08:58:46 +01:00
mrhid6 cf9d85b3cd feat: store workload reports and route log results 2026-08-07 08:56:26 +01:00
mrhid6 6a4ef5b6c6 feat: workload registry proto messages 2026-08-07 08:53:33 +01:00
mrhid6 501cf4e733 feat: agent reads bounded workload logs 2026-08-07 08:49:42 +01:00
mrhid6 89c21d752a feat: agent control actions with self-protection 2026-08-07 08:48:27 +01:00
mrhid6 0e38d9d500 feat: agent enumerates systemd services 2026-08-07 08:46:59 +01:00
mrhid6 3511c34daa feat: agent enumerates docker containers 2026-08-07 08:46:03 +01:00
mrhid6 0838d1d735 feat: models and indexes for the workload registry 2026-08-07 08:45:00 +01:00
mrhid6 d1769fc886 feat: Updated vuln style
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m36s
2026-08-06 16:35:04 +01:00
mrhid6 6dced22499 fix: Fixed agent collect packages
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Successful in 21s
Agent Release / build (push) Successful in 58s
Agent Release / msi (push) Successful in 1m34s
2026-08-06 16:20:34 +01:00
mrhid6 5cee53dc5f feat: Better button description
Chart Release / chart (push) Successful in 20s
Server Deploy / deploy (push) Successful in 2m23s
2026-08-06 15:51:18 +01:00
mrhid6 81248bb159 style: bring the vulnerabilities page onto the house page shape
Every other page under app/(app) opens with `p-4 sm:p-6 lg:p-8` and the
layout adds none of its own, so this page alone sat flush against the shell
edge. Its h1 was text-xl where every other page is text-2xl.

The findings list was a stack of separately bordered cards; it is now rows
inside one Card, separated by border-border-soft, matching the monitors and
workflows lists. Loading is the shared spinner rather than a line of text,
the error is the shared danger strip, and the empty state uses the same
proportions as the monitors one.
2026-08-06 15:49:39 +01:00
mrhid6 6354d54de8 feat: added addon price to pricing page 2026-08-06 15:48:20 +01:00
mrhid6 da6d64f95c fix: give the scratch server image a /tmp for the vulnerability database
The runtime stage is FROM scratch, which has no /tmp, so vulnsched died at
startup with "temp dir: stat /tmp: no such file or directory" and no scan
ever ran. Nothing in the server wrote to a temporary directory before the
trivy-db puller, which is why this only appeared now.

scratch cannot mkdir its own, so the directory is staged in the builder at
1777 and copied in. Also corrects CLAUDE.md, which described this image as
Alpine; the time/tzdata import it justifies is if anything more load-bearing
on scratch.
2026-08-06 15:46:30 +01:00
mrhid6 9ba3d4a61f feat: Vulnerability Scanning feature on license page
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m20s
2026-08-06 15:40:13 +01:00
mrhid6 eee236a072 fix: tidy server go.mod for the vulnerability database dependencies
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Canceled after 2m11s
trivy-db, oras-go, the three version comparators and robfig/cron were
recorded as indirect with an incomplete go.sum, which builds locally
against a warm module cache but fails in CI with "updates to go.mod
needed". trivy-db pulls testify into the build graph, and its hashes
were missing entirely.
2026-08-06 15:18:57 +01:00
mrhid6 9df89e2db4 fix: surface vuln_scanning across licence, staff and pricing UI
Chart Release / chart (push) Successful in 11s
Server Deploy / deploy (push) Canceled after 59s
The catalogue row alone was not enough; the feature was invisible in
three places and mislabelled in a fourth.

PlanConfigurator rendered every key that was not "console" as "Single
sign-on", so the staff checkbox granting vulnerability scanning was
labelled single sign-on. Feature wording was duplicated between the staff
configurator and the purchase form and the copies had drifted, so it now
lives in adminsite/lib/features.ts and both read from it.

The customer licence panel showed raw keys; it now labels them.

Pricing gains a comparison row. The add-on block with a monthly price is
deliberately NOT added: that is a pricing decision, and the Paddle price
IDs for the new catalogue rows have to be pasted in before it can be sold
anyway.
2026-08-06 15:10:26 +01:00
50 changed files with 2751 additions and 247 deletions
+85 -3
View File
@@ -147,7 +147,7 @@ with `"schedule"` as the actor, so there is no second dispatch path and the run
detail page needed no changes.
`main.go` imports `_ "time/tzdata"`, and it is load-bearing: `server/Dockerfile`
builds on Alpine, which ships no zone database, so without it
runs on `scratch`, which ships no zone database, so without it
`time.LoadLocation("Europe/London")` fails and every schedule silently falls
back to UTC — an hour wrong for half the year, in the direction nobody notices
until a maintenance window lands in business hours. It works on a developer
@@ -350,10 +350,86 @@ is where risk goes to be forgotten. An unsupported distribution reports
unknown is the same lie as a silently stale database, which is why
`vulndb_meta.pulled_at` is on screen rather than only in a log.
**`server/Dockerfile`'s runtime stage is `scratch`, so it carries an explicitly
copied `/tmp`.** The scheduler unpacks the database to a temporary directory,
and a scratch image has none — the failure is `vulnsched: temp dir: stat /tmp:
no such file or directory`, logged once at boot while every other subsystem
runs normally, so the only symptom is a fleet that never reports a finding.
Two environment variables: `VANTAGE_TRIVY_DB_REF` mirrors the artifact for
air-gapped installs, and `VANTAGE_VULNDB_DISABLED` switches the puller and
scheduler off entirely.
### Workload registry
A **workload** is one Docker container or one systemd unit — one word for the
page, the collection and the commands, rather than saying "container or
service" in every identifier. Linux only, and **not gated by licence**: this
reads as core fleet management, so v1 ships everywhere with no `HasFeature`
check. If that changes the check belongs at `ReportWorkloads`, gating collection
rather than display, exactly as sub-project A does.
Agents collect on a 60-second ticker and report through `ReportWorkloads` with
the **offer-then-send** handshake the package report already uses. The offer is
identified by an explicit `full` flag, **not by an empty workloads list**: a
host genuinely running nothing sends an empty list as its full report, and
inferring the offer from emptiness leaves that host answering `need_full` every
60 seconds forever and never storing anything.
**The on-demand refresh returns no data.** `RefreshWorkloadsCmd` carries nothing
back; it makes the agent report through the normal RPC and the UI refetches. A
refresh that returned workloads inline would be a second writer for
`server_workloads`, arriving by a different route with its own serialisation and
its own opportunity to disagree with the periodic one. One writer, one shape.
Opening the panel dispatches a refresh because the panel has a Restart button on
it, and a stale row is a wrong action aimed at a container that already died.
Two operations do answer back, both over the bus, both with `Await` called
**before** dispatch: control actions reuse the existing `CommandResult`, and log
reads get `WorkloadLogsResult`. `CommandStream` republishes **every**
`CommandResult` onto `bus.ResultChannel` — publishing with no subscriber is a
no-op, so this costs nothing and avoids a second result path.
**The protected set is computed agent-side and enforced agent-side.**
`vantage-agent.service`, plus the container ID read from `/proc/self/cgroup`
should the agent ever run in a container. As with the console relay hardcoding
`127.0.0.1`, 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, and the failure is unrecoverable from the UI. The
reported `Protected` flag is the courtesy that greys the button; the agent's own
check is the boundary. The API answers **409** when it fires — nothing failed.
Collection avoids parsing English: `docker ps -aq` then
`docker inspect --format '{{json .}}'`, because `docker ps` reports health and
uptime inside a human `Status` string that is localised and reworded between
releases. Compose stacks come from the `com.docker.compose.project` label, never
from YAML on disk — a compose file there may not be what is running. systemd
uses **column** output, not `--output=json`, which needs systemd 246+.
`DockerOK`/`DockerError` are two fields because there are three states: not
installed (common on this fleet, and not a fault), installed but not responding,
and running nothing. The UI must render the first as "not in use here" rather
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
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.
`server_workloads` is one document per server, mirroring `server_packages`, and
is in `ScopedCollections` (which `scopedCollectionsForPurge` derives from). There
is no history: a workload list is state, not a record.
**`proto/vantage/v1/vantage.proto` is documentation, not a generator input.**
Both `pb` packages are hand-written JSON-tagged structs over a custom codec, and
there are two copies — `agent/internal/grpc/pb` and `server/internal/grpc/pb`.
A message added to one must be added to the other and to the `.proto`, in the
same commit.
### Agent self-update
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
@@ -506,6 +582,7 @@ service Vantage {
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
rpc ReportWorkloads(ReportWorkloadsRequest) returns (ReportWorkloadsResponse);
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
@@ -515,7 +592,8 @@ service Vantage {
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, or `StepOutputChunk`.
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`.
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`, `OpenProxyCmd`, `PingCmd`, `RefreshWorkloadsCmd`, `ControlWorkloadCmd`,
`WorkloadLogsCmd`.
**`PingCmd` is a liveness beat, and it is not redundant with gRPC keepalive.**
The server sends one every 20s on an otherwise idle command stream; the agent
@@ -572,6 +650,10 @@ vulns GET /vulnerabilities · GET /vulnerabilities/summary
GET /servers/:id/vulnerabilities · GET /servers/:id/packages
GET /packages/search?name=
GET,POST /vuln-rules · PUT,DELETE /vuln-rules/:id (owner|admin)
workloads GET /workloads · GET /servers/:id/workloads
POST /servers/:id/workloads/refresh
POST /servers/:id/workloads/:wid/action (owner|admin)
GET /servers/:id/workloads/:wid/logs (owner|admin)
audit GET /audit
agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
@@ -652,7 +734,7 @@ Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no
## MongoDB Collections
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `migrations`
`servers` · `keys` · `assignments` · `orgs` · `users` · `auth_providers` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `workflow_log_lines` · `workflow_log_seq` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `server_packages` · `vuln_findings` · `vuln_alert_rules` · `vulndb_meta` · `server_workloads` · `migrations`
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
+2 -1
View File
@@ -67,7 +67,8 @@ func (r CatalogueRow) Priced(env string) bool {
return false
}
// SeedCatalogue inserts the sixteen rows the four PAID plans need.
// SeedCatalogue inserts the twenty rows the four PAID plans need: a base, a
// server limit, and one row per feature key.
//
// The two Free plans get no rows at all, and that absence is what keeps Free
// outside Paddle: with nothing to price, no checkout can be built for it. Do not
@@ -12,6 +12,7 @@ import { StatePill } from "@/components/StatePill";
import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame";
import { PageHeader } from "@/components/PageHeader";
import { formatDate, licenceState, limitLabel } from "@/lib/format";
import { featureLabel } from "@/lib/features";
export default function InstancePage() {
const id = String(useParams().id);
@@ -104,7 +105,11 @@ export default function InstancePage() {
},
{
label: "Features",
value: lic.features.join(", ") || "none",
// Labelled, not raw keys: this is
// the customer's own licence, and
// "vuln_scanning" is not a name
// anyone bought.
value: lic.features.map(featureLabel).join(", ") || "none",
},
]}
/>
@@ -6,25 +6,16 @@ import Link from "next/link";
import { useMutation, useQuery } from "@tanstack/react-query";
import { ApiError, api, lineItemsFor, type CatalogueRow, type CheckoutOptions, type Deployment, type Plan, type Term, type Tier } from "@/lib/api";
import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle";
import { featureDesc, featureLabel } from "@/lib/features";
/* Tiers in the order a customer reads them, cheapest first. */
const TIER_ORDER: Tier[] = ["free", "professional", "enterprise"];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/* Human labels for feature keys. The catalogue names them by key; this is the
* one place the customer-facing wording lives. */
const FEATURE_LABEL: Record<string, string> = {
console: "Browser console",
oidc: "Single sign-on",
};
const FEATURE_DESC: Record<string, string> = {
console: "In-browser SSH, RDP and VNC sessions",
oidc: "OIDC sign-in for your whole team",
};
function featureLabel(key: string) {
return FEATURE_LABEL[key] ?? key;
}
/* Feature wording lives in lib/features.ts, shared with the staff
* configurator. It was duplicated here and there, and the two copies had
* already drifted. */
interface Choice {
tier: Tier;
@@ -294,7 +285,7 @@ export function PurchaseForm() {
{featureKeys.map((key) => {
const st = featureStateFor(plan, rows, options.env, choice.term, key);
return (
<Row key={key} title={featureLabel(key)} desc={FEATURE_DESC[key] ?? ""} dim={st === "absent"}>
<Row key={key} title={featureLabel(key)} desc={featureDesc(key)} dim={st === "absent"}>
{st === "included" ? (
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-valid">Included</span>
) : st === "absent" ? (
+2 -1
View File
@@ -2,6 +2,7 @@
import { useMemo } from "react";
import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api";
import { featureLabel } from "@/lib/features";
export interface PlanChoice {
tier: Tier;
@@ -159,7 +160,7 @@ export default function PlanConfigurator({
})
}
/>
<span>{key === "console" ? "Browser console" : "Single sign-on"}</span>
<span>{featureLabel(key)}</span>
<span className="text-[0.72rem] text-ink-3">
{priced ? "paid add-on" : "included"}
</span>
+27
View File
@@ -0,0 +1,27 @@
/* Human wording for licence feature keys.
*
* One place, because there were two and they disagreed: the staff configurator
* rendered every key that was not "console" as "Single sign-on", so adding a
* third feature silently mislabelled the checkbox that grants it. A map with a
* fallback degrades to the raw key, which is ugly but never wrong.
*
* Keys must match shared/license/license.go. */
export const FEATURE_LABEL: Record<string, string> = {
console: "Browser console",
oidc: "Single sign-on",
vuln_scanning: "Vulnerability scanning",
};
export const FEATURE_DESC: Record<string, string> = {
console: "In-browser SSH, RDP and VNC sessions",
oidc: "OIDC sign-in for your whole team",
vuln_scanning: "Package inventory matched against distribution security advisories",
};
export function featureLabel(key: string): string {
return FEATURE_LABEL[key] ?? key;
}
export function featureDesc(key: string): string {
return FEATURE_DESC[key] ?? "";
}
+13
View File
@@ -113,6 +113,19 @@ func (c *Client) ReportPackages(req *pb.ReportPackagesRequest) (bool, error) {
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()
+7
View File
@@ -206,6 +206,10 @@ type ServerCommand struct {
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
@@ -243,6 +247,8 @@ type AgentMessage struct {
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{}
@@ -377,6 +383,7 @@ type VantageClient interface {
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)
+80
View File
@@ -0,0 +1,80 @@
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
}
+35
View File
@@ -1,9 +1,12 @@
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"
@@ -20,6 +23,38 @@ import (
// 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
+16
View File
@@ -70,6 +70,8 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
go runInventory(ctx, cfg)
go runWorkloads(ctx, cfg)
go monitors.Run(ctx, cfg)
ticker := time.NewTicker(cfg.PollInterval)
@@ -101,6 +103,7 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
// 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
@@ -346,6 +349,15 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
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) {
@@ -409,6 +421,10 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
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()
+161
View File
@@ -0,0 +1,161 @@
package agentsync
import (
"context"
"log"
"runtime"
"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"
)
// 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) {
if runtime.GOOS != "linux" {
return
}
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) {
if runtime.GOOS != "linux" {
return
}
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,
})
}
+105
View File
@@ -0,0 +1,105 @@
package workloads
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"time"
)
// ErrProtected is returned for a workload the agent will not act on.
var ErrProtected = errors.New("workload is protected")
// AgentUnit is the systemd unit this agent runs as.
const AgentUnit = "vantage-agent.service"
// controlTimeout bounds a stop that may never finish on its own. `docker stop`
// waits on a container that may ignore SIGTERM, and `systemctl stop` on a unit
// with a long TimeoutStopSec blocks for exactly as long as that says. A
// timeout must return a real error rather than an ack implying success.
const controlTimeout = 90 * time.Second
// 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 ""
}
// 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 id == AgentUnit || name == strings.TrimSuffix(AgentUnit, ".service")
}
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()
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
}
+141
View File
@@ -0,0 +1,141 @@
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()
}
+83
View File
@@ -0,0 +1,83 @@
package workloads
import (
"context"
"fmt"
"os/exec"
"strconv"
"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()
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 "", false, 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 "", false, fmt.Errorf("read logs for %s: %s", id, errText(err))
}
text, truncated := capLog(string(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
}
+94
View File
@@ -0,0 +1,94 @@
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"}
// collectSystemd 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 collectSystemd(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
}
+64
View File
@@ -0,0 +1,64 @@
package workloads
import (
"context"
"crypto/sha256"
"encoding/hex"
"runtime"
"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. Linux only.
func Collect(ctx context.Context) Result {
if runtime.GOOS != "linux" {
return Result{}
}
var r Result
containers, dockerOK, dockerErr := collectDocker(ctx)
units, systemdOK, systemdErr := collectSystemd(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))
}
+90
View File
@@ -0,0 +1,90 @@
---
id: workloads
title: Workloads
sidebar_label: Workloads
---
A **workload** is one Docker container or one systemd service. Each Linux
server reports what it runs, and you can start, stop and restart those
workloads — and read a snapshot of their logs — without opening a console.
Available on every instance. No licence feature is required.
## What gets reported
Linux servers only. Agents report every 60 seconds, and an unchanged list costs
a single small message rather than the whole thing again.
- **Containers** — every container, running or not, with its image, published
ports, health, restart count and the compose stack it belongs to.
- **Services** — systemd units that are running or failed, plus units that are
enabled but currently stopped. The platform's own units (`systemd-*`,
`user@*`, `session-*`) are filtered out; a typical host has 300 of them and
they bury the ten you care about.
Windows servers report no workloads at all.
## Docker not in use is not an error
Three different things look identical if you are careless, and only one of them
is a problem:
| What you see | What it means |
| ------------ | ------------- |
| "Docker is not in use on this server" | Docker is not installed. Normal, and not a fault |
| "Docker is installed but not responding" | The daemon is down or the socket is unreachable |
| An empty container list | Docker is running and there are no containers |
## Stacks are grouped
Compose stacks appear first, grouped under the stack name, then loose
containers, then services. A stack is one thing even when it is six containers,
and a flat list turns one decision into six rows.
The stack name comes from Docker's own `com.docker.compose.project` label. No
compose file is read from disk — a file on disk may not be what is running.
## Controlling a workload
Start, stop and restart are **owner or admin only**, and every action is
written to the audit log naming you, the server and the target.
The agent refuses to act on itself. `vantage-agent.service` is shown with its
buttons disabled: a server that stops its own agent goes offline, and the only
way back is SSH or physical access — which is exactly what this page exists to
avoid needing.
A stop that never finishes is not reported as success. Both `docker stop` and
`systemctl stop` run under a 90-second limit, and a timeout comes back as a
real error.
## Reading logs
Logs are **owner or admin only** and every read is audited. Unlike workflow
logs, a container's output cannot be masked: it is arbitrary, and a startup
banner or a stack trace may contain credentials nobody declared.
A log read returns a snapshot of at most **500 lines or 256KB**, whichever
limit is reached first, with the most recent output kept. When either limit
binds, the dialog says so — a truncated log must never be read as a complete
one.
There is no live following. The [browser console](./browser-console.md) already
gives you a real terminal on the same server, where `docker logs -f` works
properly with its own scrollback.
## Refreshing
Opening a server's Workloads panel asks its agent to report immediately, so
what is on screen is current rather than up to a minute old. That matters
because the panel has a Restart button on it: a stale row is not just a wrong
impression, it is a wrong action aimed at something that already died.
If the agent is offline the refresh fails visibly rather than queueing. A
command whose target cannot be reached must say so.
## Fleet view
**Workloads** in the sidebar searches the whole fleet by image, stack or state
— "which of these servers is still on the old image" — and links each result
back to its server.
+1
View File
@@ -27,6 +27,7 @@ const sidebars: SidebarsConfig = {
"vantage/workflows",
"vantage/monitors",
"vantage/vulnerabilities",
"vantage/workloads",
"vantage/notification-channels",
"vantage/secrets",
"vantage/browser-console",
+8 -15
View File
@@ -5,7 +5,6 @@ github.com/Intevation/jsonpath v0.2.1/go.mod h1:WnZ8weMmwAx/fAO3SutjYFU+v7DFreNY
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
github.com/aquasecurity/go-gem-version v0.0.0-20201115065557-8eed6fe000ce/go.mod h1:HXgVzOPvXhVGLJs4ZKO817idqr/xhwsTcj17CLYY74s=
github.com/aquasecurity/go-npm-version v0.0.1/go.mod h1:hxbJZtKlO4P8sZ9nztizR6XLoE33O+BkPmuYQ4ACyz0=
github.com/aquasecurity/go-pep440-version v0.0.1/go.mod h1:3naPe+Bp6wi3n4l5iBFCZgS0JG8vY6FT0H4NGhFJ+i4=
@@ -19,9 +18,10 @@ github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t
github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0=
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gocsaf/csaf/v3 v3.1.1/go.mod h1:EpUCrQg69i+Y66MphmQvVbcj333GFLjXOYHg1zoXVso=
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -29,6 +29,7 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/josephburnett/jd/v2 v2.3.0/go.mod h1:0I5+gbo7y8diuajJjm79AF44eqTheSJy1K7DSbIUFAQ=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
@@ -36,28 +37,20 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
github.com/masahiro331/go-mvn-version v0.0.0-20250131095131-f4974fa13b8a/go.mod h1:jZ3F25l7DbD7l7DcA8aj7eo1EZ84nbzcQHBB4lCSrI8=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/package-url/packageurl-go v0.1.3/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0=
github.com/pandatix/go-cvss v0.6.2/go.mod h1:jDXYlQBZrc8nvrMUVVvTG8PhmuShOnKrxP53nOFkt8Q=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
github.com/samber/oops v1.18.1 h1:qjhZbqbdyhWBKntkY8sxrDNKA8b4c5VHlmI1rli7X7M=
github.com/samber/oops v1.18.1/go.mod h1:xYqvimigkKV70HyLXiBZJFpIWi2CGcc6Xx7eV+2HycI=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.etcd.io/gofail v0.2.0/go.mod h1:nL3ILMGfkXTekKI3clMBNazKnjUZjYLKmBHzsVAnC1o=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
+68
View File
@@ -10,6 +10,7 @@ service Vantage {
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
rpc ReportPackages(ReportPackagesRequest) returns (ReportPackagesResponse);
rpc ReportWorkloads(ReportWorkloadsRequest) returns (ReportWorkloadsResponse);
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
@@ -107,6 +108,7 @@ message AgentMessage {
CommandResult result = 4;
StepResult step_result = 5;
StepOutputChunk step_output = 6;
WorkloadLogsResult workload_logs_result = 7;
}
}
@@ -229,6 +231,9 @@ message ServerCommand {
CleanupWorkspaceCmd cleanup_workspace = 7;
OpenProxyCmd open_proxy = 8;
PingCmd ping = 9;
RefreshWorkloadsCmd refresh_workloads = 10;
ControlWorkloadCmd control_workload = 11;
WorkloadLogsCmd workload_logs = 12;
}
}
@@ -319,3 +324,66 @@ message ProxyServerMsg {
ProxyClose close = 2;
}
}
// ---------------------------------------------------------------------------
// Workload registry
// ReportWorkloads 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 need_full is set.
message ReportWorkloadsRequest {
string server_id = 1;
string agent_token = 2;
string hash = 3;
bool docker_ok = 4;
string docker_error = 5;
bool systemd_ok = 6;
string systemd_error = 7;
repeated Workload workloads = 8; // empty on the offer call
// full marks the second call. It is not inferred from an empty workloads
// list: a host running nothing sends an empty list as its full report.
bool full = 9;
}
message ReportWorkloadsResponse {
bool need_full = 1;
}
message Workload {
string kind = 1; // "container" | "unit"
string id = 2;
string name = 3;
string state = 4;
string health = 5;
string image = 6;
string stack = 7;
repeated string ports = 8;
int32 restarts = 9;
string started_at = 10; // RFC3339, empty when not running
bool protected = 11;
}
// 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.
message RefreshWorkloadsCmd {}
message ControlWorkloadCmd {
string kind = 1;
string id = 2;
string action = 3; // "start" | "stop" | "restart"
}
message WorkloadLogsCmd {
string kind = 1;
string id = 2;
int32 tail = 3;
}
message WorkloadLogsResult {
string command_id = 1;
string text = 2;
bool truncated = 3;
string error = 4;
}
+9
View File
@@ -18,10 +18,19 @@ ARG VERSION=dev
RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
# Staged so the scratch image below can have a /tmp. It cannot mkdir one
# itself — scratch has no shell — and os.MkdirTemp fails outright without it.
RUN mkdir -p /staging/tmp && chmod 1777 /staging/tmp
# Runtime stage
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# vulndb unpacks the ~50MB trivy-db here. Without it the scheduler stops at
# "temp dir: stat /tmp: no such file or directory" and no scanning happens,
# while everything else in the process runs perfectly well.
COPY --from=builder /staging/tmp /tmp
COPY --from=builder /vantage-server /vantage-server
COPY default_steps/ /opt/default-steps/
+4
View File
@@ -136,6 +136,10 @@ func runSchemaSetup() {
log.Printf("warning: failed to ensure vuln indexes: %v", err)
}
if err := services.EnsureWorkloadIndexes(); err != nil {
log.Printf("warning: failed to ensure workload indexes: %v", err)
}
if instanceIDs, err := services.ListInstanceIDs(); err != nil {
log.Printf("warning: failed to list instances for default step seeding: %v", err)
} else {
+17 -7
View File
@@ -3,27 +3,37 @@ module gitea.hostxtra.co.uk/mrhid6/vantage/server
go 1.26
require (
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54
github.com/coreos/go-oidc/v3 v3.18.0
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f
github.com/opencontainers/image-spec v1.1.1
github.com/redis/go-redis/v9 v9.20.1
github.com/robfig/cron/v3 v3.0.1
github.com/wwt/guac v1.3.2
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
golang.org/x/oauth2 v0.36.0
google.golang.org/grpc v1.64.0
oras.land/oras-go/v2 v2.6.2
)
require (
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f // indirect
github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 // indirect
github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
oras.land/oras-go/v2 v2.6.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/samber/lo v1.50.0 // indirect
github.com/samber/oops v1.18.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
)
require (
+24 -1
View File
@@ -1,3 +1,5 @@
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM=
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54 h1:4CZNoDkNfcuACevZeDraACGmP1+L0nKkRY52+jV8k1M=
github.com/aquasecurity/trivy-db v0.0.0-20260713131703-4be526083c54/go.mod h1:iIEV2oGuZScvfyX2SMIn78iVMNnepgo0QuJJh/srgVI=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@@ -37,6 +39,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -63,6 +67,8 @@ github.com/knqyf263/go-rpm-version v0.0.0-20240918084003-2afd7dc6a38f/go.mod h1:
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -72,10 +78,15 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -84,12 +95,17 @@ github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsT
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY=
github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc=
github.com/samber/oops v1.18.1 h1:qjhZbqbdyhWBKntkY8sxrDNKA8b4c5VHlmI1rli7X7M=
github.com/samber/oops v1.18.1/go.mod h1:xYqvimigkKV70HyLXiBZJFpIWi2CGcc6Xx7eV+2HycI=
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -119,8 +135,14 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
@@ -169,8 +191,9 @@ 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.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
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/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+9
View File
@@ -132,6 +132,15 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/vuln-rules", auth.RequireRole("owner", "admin"), createVulnRule)
apiGroup.PUT("/vuln-rules/:id", auth.RequireRole("owner", "admin"), updateVulnRule)
apiGroup.DELETE("/vuln-rules/:id", auth.RequireRole("owner", "admin"), deleteVulnRule)
// Control actions and log reads are owner|admin: container output is
// arbitrary and cannot be masked, so a member who can see the fleet
// still cannot read its logs.
apiGroup.GET("/workloads", listWorkloads)
apiGroup.GET("/servers/:id/workloads", getServerWorkloads)
apiGroup.POST("/servers/:id/workloads/refresh", refreshServerWorkloads)
apiGroup.POST("/servers/:id/workloads/:wid/action", auth.RequireRole("owner", "admin"), controlWorkload)
apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
}
}
+196
View File
@@ -0,0 +1,196 @@
package api
import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"github.com/gin-gonic/gin"
)
// getServerWorkloads returns the stored snapshot.
//
// A server that has never reported answers an empty list rather than 404: the
// agent may simply not have got there yet, and 404 reads as "no such server".
func getServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
if _, err := services.GetServer(instanceID, id); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
sw, err := services.GetWorkloads(instanceID, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if sw == nil {
c.JSON(http.StatusOK, models.ServerWorkloads{
ServerID: id,
Workloads: []models.Workload{},
})
return
}
if sw.Workloads == nil {
sw.Workloads = []models.Workload{}
}
c.JSON(http.StatusOK, sw)
}
// refreshServerWorkloads nudges the agent to report now. It returns no data:
// the client refetches the stored document once the agent has written it.
func refreshServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
s, err := services.GetServer(instanceID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := services.DispatchRefreshWorkloads(s.ServerID); err != nil {
// Not queued: a command whose owner died must fail loudly, so the
// client can show the stored snapshot as stale rather than pretend.
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"message": "refresh requested"})
}
func controlWorkload(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
wid, ok := workloadIDParam(c)
if !ok {
return
}
var body struct {
Action string `json:"action"`
Kind string `json:"kind"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
switch body.Action {
case models.WorkloadStart, models.WorkloadStop, models.WorkloadRestart:
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "action must be start, stop or restart"})
return
}
if body.Kind != models.WorkloadContainer && body.Kind != models.WorkloadUnit {
c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be container or unit"})
return
}
s, err := services.GetServer(instanceID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
err = services.DispatchControlWorkload(s.ServerID, body.Kind, wid, body.Action)
if err != nil {
switch {
case errors.Is(err, services.ErrAgentNotConnected):
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
case services.IsWorkloadProtected(err):
// Nothing failed — the agent refused, which is the design. 409, not
// 500, and the reason is carried through.
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
return
}
services.LogEvent(instanceID, "workload."+body.Action, actorFromCtx(c), s.ServerID, "",
fmt.Sprintf("%s %s %s on %s", body.Action, body.Kind, wid, s.Hostname))
c.JSON(http.StatusOK, gin.H{"message": body.Action + " ok"})
}
func getWorkloadLogs(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
wid, ok := workloadIDParam(c)
if !ok {
return
}
kind := c.DefaultQuery("kind", models.WorkloadContainer)
if kind != models.WorkloadContainer && kind != models.WorkloadUnit {
c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be container or unit"})
return
}
// Clamped rather than refused: a client asking for more than the cap gets
// the cap, which is what it would have got anyway.
tail, _ := strconv.Atoi(c.Query("tail"))
if tail <= 0 || tail > services.MaxWorkloadLogLines {
tail = services.MaxWorkloadLogLines
}
s, err := services.GetServer(instanceID, id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
text, truncated, err := services.DispatchWorkloadLogs(s.ServerID, kind, wid, tail)
if err != nil {
if errors.Is(err, services.ErrAgentNotConnected) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
// Audited because container output is arbitrary and cannot be masked: a
// startup banner or a stack trace may carry credentials nobody declared.
services.LogEvent(instanceID, "workload.logs_read", actorFromCtx(c), s.ServerID, "",
fmt.Sprintf("read %s logs for %s on %s", kind, wid, s.Hostname))
c.JSON(http.StatusOK, gin.H{"text": text, "truncated": truncated})
}
// listWorkloads answers the fleet-wide question, which is the reason the
// snapshot is stored rather than fetched on demand and discarded.
func listWorkloads(c *gin.Context) {
hits, err := services.SearchWorkloads(auth.InstanceID(c),
c.Query("image"), c.Query("stack"), c.Query("state"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, hits)
}
// workloadIDParam decodes :wid. Unit names carry dots and '@', so the client
// encodes it and this is where it comes back.
func workloadIDParam(c *gin.Context) (string, bool) {
raw := c.Param("wid")
decoded, err := url.PathUnescape(raw)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workload id"})
return "", false
}
decoded = strings.TrimSpace(decoded)
if decoded == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "workload id is required"})
return "", false
}
return decoded, true
}
+9
View File
@@ -198,6 +198,10 @@ type ServerCommand struct {
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
@@ -233,6 +237,8 @@ type AgentMessage struct {
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{}
@@ -366,6 +372,7 @@ type VantageServer interface {
UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error)
ReportUpdates(context.Context, *ReportUpdatesRequest) (*ReportUpdatesResponse, error)
ReportPackages(context.Context, *ReportPackagesRequest) (*ReportPackagesResponse, error)
ReportWorkloads(context.Context, *ReportWorkloadsRequest) (*ReportWorkloadsResponse, error)
ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error)
SyncMonitors(context.Context, *SyncMonitorsRequest) (*SyncMonitorsResponse, error)
ReportChecks(context.Context, *ReportChecksRequest) (*ReportChecksResponse, error)
@@ -421,6 +428,7 @@ type VantageClient interface {
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)
@@ -529,6 +537,7 @@ var Vantage_ServiceDesc = grpc.ServiceDesc{
{MethodName: "UploadGeneratedKey", Handler: _Vantage_UploadGeneratedKey_Handler},
{MethodName: "ReportUpdates", Handler: _Vantage_ReportUpdates_Handler},
{MethodName: "ReportPackages", Handler: _Vantage_ReportPackages_Handler},
{MethodName: "ReportWorkloads", Handler: _Vantage_ReportWorkloads_Handler},
{MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler},
{MethodName: "SyncMonitors", Handler: _Vantage_SyncMonitors_Handler},
{MethodName: "ReportChecks", Handler: _Vantage_ReportChecks_Handler},
+101
View File
@@ -0,0 +1,101 @@
package pb
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// 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. The agent module carries the same declarations.
// 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 (UnimplementedVantageServer) ReportWorkloads(context.Context, *ReportWorkloadsRequest) (*ReportWorkloadsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReportWorkloads not implemented")
}
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
}
func _Vantage_ReportWorkloads_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReportWorkloadsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VantageServer).ReportWorkloads(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportWorkloads"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VantageServer).ReportWorkloads(ctx, req.(*ReportWorkloadsRequest))
}
return interceptor(ctx, in, info, handler)
}
+71
View File
@@ -163,6 +163,70 @@ func (s *vantageServer) ReportPackages(ctx context.Context, req *pb.ReportPackag
return &pb.ReportPackagesResponse{NeedFull: false}, nil
}
// ReportWorkloads stores what a server is running.
//
// It is not gated by licence: the workload registry reads as core fleet
// management rather than a premium add-on. If that ever changes, the check
// belongs here — gating collection, not display — for the same reason it does
// in ReportPackages.
func (s *vantageServer) ReportWorkloads(ctx context.Context, req *pb.ReportWorkloadsRequest) (*pb.ReportWorkloadsResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
// The offer call: a hash and no body. Answering NeedFull=false here is what
// keeps an unchanged 60-second report to one small message.
//
// The offer is identified by Full, not by an empty Workloads slice: a host
// genuinely running nothing sends an empty list as its FULL report, and
// inferring the offer from emptiness would leave that host answering
// NeedFull=true forever and never storing anything.
if !req.Full {
known, err := services.HasWorkloadHash(srv.InstanceID, srv.ServerID, req.Hash)
if err != nil {
log.Printf("workload hash lookup for %s: %v", srv.ServerID, err)
return nil, status.Errorf(codes.Internal, "workload hash lookup failed")
}
return &pb.ReportWorkloadsResponse{NeedFull: !known}, nil
}
wls := make([]models.Workload, len(req.Workloads))
for i, w := range req.Workloads {
wls[i] = models.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: int(w.Restarts),
Protected: w.Protected,
}
if w.StartedAt != "" {
if t, err := time.Parse(time.RFC3339, w.StartedAt); err == nil {
wls[i].StartedAt = t
}
}
}
if err := storeWorkloadReport(srv.InstanceID, srv.ServerID, req, wls); err != nil {
log.Printf("store workloads for %s: %v", srv.ServerID, err)
return nil, status.Errorf(codes.Internal, "failed to store workloads")
}
return &pb.ReportWorkloadsResponse{NeedFull: false}, nil
}
func storeWorkloadReport(instanceID, serverID string, req *pb.ReportWorkloadsRequest, wls []models.Workload) error {
if wls == nil {
wls = []models.Workload{}
}
return services.StoreWorkloads(instanceID, serverID, req.Hash, wls,
req.DockerOk, req.DockerError, req.SystemdOk, req.SystemdError)
}
func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryReport) (*pb.InventoryReportResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
@@ -257,6 +321,13 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
if m.Result != nil {
r := m.Result
log.Printf("agent %s cmd %s: success=%v %s", srv.ServerID, r.CommandId, r.Success, r.Message)
// Republished so a control action waiting on another pod sees
// it. Publishing with no subscriber is a no-op, so this is safe
// for every command result rather than only the awaited ones.
services.WorkloadResults.DeliverCommand(r)
}
if m.WorkloadLogsResult != nil {
services.WorkloadResults.Deliver(m.WorkloadLogsResult)
}
if m.StepResult != nil {
services.StepResults.Deliver(m.StepResult)
+70
View File
@@ -0,0 +1,70 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Workload kinds.
const (
WorkloadContainer = "container"
WorkloadUnit = "unit"
)
// Control actions.
const (
WorkloadStart = "start"
WorkloadStop = "stop"
WorkloadRestart = "restart"
)
// Workload is one container or one systemd unit.
type Workload struct {
Kind string `bson:"kind" json:"kind"` // container | unit
ID string `bson:"id" json:"id"` // container id, or unit name
Name string `bson:"name" json:"name"`
// State is deliberately NOT collapsed into a shared vocabulary across the
// two kinds. Containers report running/exited/paused/restarting/created;
// units report active/inactive/failed/activating. A failed unit and an
// exited container mean different things, and flattening them loses the
// distinction the operator needs.
State string `bson:"state" json:"state"`
Health string `bson:"health,omitempty" json:"health,omitempty"`
Image string `bson:"image,omitempty" json:"image,omitempty"`
Stack string `bson:"stack,omitempty" json:"stack,omitempty"` // compose project label
Ports []string `bson:"ports,omitempty" json:"ports,omitempty"`
Restarts int `bson:"restarts,omitempty" json:"restarts,omitempty"`
StartedAt time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
// Protected is computed agent-side and reported so the UI can render the
// action disabled with a reason rather than offering a button whose refusal
// is already known. The field is the courtesy; the agent's own check is the
// boundary.
Protected bool `bson:"protected" json:"protected"`
}
// ServerWorkloads holds one server's whole workload list in ONE document.
type ServerWorkloads struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"-"`
ServerID string `bson:"server_id" json:"server_id"`
Hash string `bson:"hash" json:"hash"`
Workloads []Workload `bson:"workloads" json:"workloads"`
CollectedAt time.Time `bson:"collected_at" json:"collected_at"`
// A host with no Docker and a host with Docker running nothing both produce
// an empty list. One should read "not in use here", the other "nothing
// running", and only the second deserves any alarm.
//
// The error strings separate a third case the booleans cannot: installed
// with the daemon down. "Not installed" and "installed but not responding"
// are different problems with different fixes.
DockerOK bool `bson:"docker_ok" json:"docker_ok"`
DockerError string `bson:"docker_error,omitempty" json:"docker_error,omitempty"`
SystemdOK bool `bson:"systemd_ok" json:"systemd_ok"`
SystemdError string `bson:"systemd_error,omitempty" json:"systemd_error,omitempty"`
}
@@ -43,6 +43,7 @@ var ScopedCollections = []string{
"server_packages",
"vuln_findings",
"vuln_alert_rules",
"server_workloads",
}
// collectionRenames maps the two collections whose names change. Ordered so the
@@ -0,0 +1,34 @@
package services
import (
"context"
"log"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// EnsureWorkloadIndexes declares the indexes for the workload registry.
//
// It warns rather than being fatal, matching EnsureSecretIndexes and
// EnsureVulnIndexes: a missing index degrades these queries to a collection
// scan, which is no reason to refuse to serve the fleet.
func EnsureWorkloadIndexes() error {
ctx := context.Background()
idx := []mongo.IndexModel{
{
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "server_id", Value: 1}},
Options: options.Index().SetUnique(true),
},
// Multikey, for the fleet-wide "which servers run image X" query, which
// is the reason the snapshot is stored rather than fetched and discarded.
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "workloads.image", Value: 1}}},
}
if _, err := db.Col("server_workloads").Indexes().CreateMany(ctx, idx); err != nil {
log.Printf("warning: server_workloads indexes: %v", err)
}
return nil
}
+128
View File
@@ -0,0 +1,128 @@
package services
import (
"context"
"encoding/json"
"log"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/bus"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
)
// Workload results travel back over the bus for the same reason commands travel
// out over it: the pod serving the HTTP request and the pod holding the agent's
// stream are two different processes, and a map in one cannot be read by the
// other.
//
// Await MUST be called before the command is dispatched, or a fast agent
// answers into a channel nobody is listening on yet. See stepresults.go.
type workloadResultRegistry struct{}
var WorkloadResults = &workloadResultRegistry{}
// Await subscribes to a command's result channel for a log snapshot.
func (r *workloadResultRegistry) Await(commandID string) (<-chan *pb.WorkloadLogsResult, func()) {
out := make(chan *pb.WorkloadLogsResult, 1)
ctx, cancel := context.WithCancel(context.Background())
raw, unsub, err := bus.Subscribe(ctx, bus.ResultChannel+commandID)
if err != nil {
log.Printf("workload results: subscribe for %s: %v", commandID, err)
cancel()
close(out)
return out, func() {}
}
go func() {
defer close(out)
select {
case <-ctx.Done():
return
case b, ok := <-raw:
if !ok {
return
}
var res pb.WorkloadLogsResult
if err := json.Unmarshal(b, &res); err != nil {
log.Printf("workload results: undecodable result for %s: %v", commandID, err)
return
}
out <- &res
}
}()
return out, func() {
cancel()
unsub()
}
}
// AwaitCommand subscribes to a command's result channel for a plain
// CommandResult, which is what a control action answers with.
//
// A control action reuses CommandResult rather than growing a message of its
// own: start, stop and restart succeed or fail, and that is exactly what
// CommandResult already says.
func (r *workloadResultRegistry) AwaitCommand(commandID string) (<-chan *pb.CommandResult, func()) {
out := make(chan *pb.CommandResult, 1)
ctx, cancel := context.WithCancel(context.Background())
raw, unsub, err := bus.Subscribe(ctx, bus.ResultChannel+commandID)
if err != nil {
log.Printf("workload results: subscribe for %s: %v", commandID, err)
cancel()
close(out)
return out, func() {}
}
go func() {
defer close(out)
select {
case <-ctx.Done():
return
case b, ok := <-raw:
if !ok {
return
}
var res pb.CommandResult
if err := json.Unmarshal(b, &res); err != nil {
log.Printf("workload results: undecodable command result for %s: %v", commandID, err)
return
}
out <- &res
}
}()
return out, func() {
cancel()
unsub()
}
}
// Deliver publishes a log result received from an agent. Called on the pod
// holding that agent's stream, which is not usually the pod waiting for it.
func (r *workloadResultRegistry) Deliver(res *pb.WorkloadLogsResult) {
if res == nil || res.CommandId == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
defer cancel()
if _, err := bus.Publish(ctx, bus.ResultChannel+res.CommandId, res); err != nil {
log.Printf("workload results: publish for %s: %v", res.CommandId, err)
}
}
// DeliverCommand republishes a CommandResult onto the bus so a waiting pod can
// see it. Publishing with no subscriber is a no-op, so this is safe to call for
// every CommandResult rather than only the ones somebody is waiting on.
func (r *workloadResultRegistry) DeliverCommand(res *pb.CommandResult) {
if res == nil || res.CommandId == "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), dispatchAckTimeout)
defer cancel()
if _, err := bus.Publish(ctx, bus.ResultChannel+res.CommandId, res); err != nil {
log.Printf("workload results: publish command result for %s: %v", res.CommandId, err)
}
}
+206
View File
@@ -0,0 +1,206 @@
package services
import (
"context"
"fmt"
"strings"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/grpc/pb"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// workloadResultTimeout bounds how long an API request waits for an agent to
// answer a control action or a log read. It is well above the agent's own
// 90-second control timeout and 60-second log timeout, so a slow-but-working
// agent reports its real error rather than being cut off by this side.
const workloadResultTimeout = 120 * time.Second
// MaxWorkloadLogLines mirrors the agent's own cap. It is declared again here
// rather than imported: agent/ is a separate module with an internal/ tree, so
// the two cannot share a constant. Change one, change the other — the same
// shape of hazard as the mirrored token blocks in the web apps.
const MaxWorkloadLogLines = 500
// workloadProtectedMarker is the text the agent's ErrProtected carries. The
// refusal crosses the wire as a string, so this is how the control plane knows
// a 409 is owed rather than a 502.
const workloadProtectedMarker = "workload is protected"
// IsWorkloadProtected reports whether an agent refused because the target is
// protected — the agent's own guard, which is the boundary. Nothing failed, so
// the API answers 409 rather than an error status.
func IsWorkloadProtected(err error) bool {
return err != nil && strings.Contains(err.Error(), workloadProtectedMarker)
}
func HasWorkloadHash(instanceID, serverID, hash string) (bool, error) {
err := db.Col("server_workloads").FindOne(context.Background(), bson.M{
"instance_id": instanceID,
"server_id": serverID,
"hash": hash,
}, options.FindOne().SetProjection(bson.M{"_id": 1})).Err()
if err == mongo.ErrNoDocuments {
return false, nil
}
return err == nil, err
}
// StoreWorkloads replaces a server's workload list.
func StoreWorkloads(instanceID, serverID, hash string, wls []models.Workload,
dockerOK bool, dockerErr string, systemdOK bool, systemdErr string) error {
_, err := db.Col("server_workloads").UpdateOne(context.Background(),
bson.M{"instance_id": instanceID, "server_id": serverID},
bson.M{"$set": bson.M{
"hash": hash,
"workloads": wls,
"collected_at": time.Now(),
"docker_ok": dockerOK,
"docker_error": dockerErr,
"systemd_ok": systemdOK,
"systemd_error": systemdErr,
}},
options.UpdateOne().SetUpsert(true),
)
return err
}
func GetWorkloads(instanceID, serverID string) (*models.ServerWorkloads, error) {
var sw models.ServerWorkloads
err := db.Col("server_workloads").FindOne(context.Background(), bson.M{
"instance_id": instanceID,
"server_id": serverID,
}).Decode(&sw)
if err == mongo.ErrNoDocuments {
return nil, nil
}
if err != nil {
return nil, err
}
return &sw, nil
}
type WorkloadHit struct {
ServerID string `json:"server_id"`
Workload models.Workload `json:"workload"`
}
// SearchWorkloads answers "which servers run image X" — the reason the snapshot
// is stored rather than fetched on demand and discarded.
func SearchWorkloads(instanceID, image, stack, state string) ([]WorkloadHit, error) {
ctx := context.Background()
filter := bson.M{"instance_id": instanceID}
if image != "" {
filter["workloads.image"] = image
}
cur, err := db.Col("server_workloads").Find(ctx, filter)
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var docs []models.ServerWorkloads
if err := cur.All(ctx, &docs); err != nil {
return nil, err
}
hits := []WorkloadHit{}
for _, d := range docs {
for _, w := range d.Workloads {
if image != "" && w.Image != image {
continue
}
if stack != "" && w.Stack != stack {
continue
}
if state != "" && w.State != state {
continue
}
hits = append(hits, WorkloadHit{ServerID: d.ServerID, Workload: w})
}
}
return hits, nil
}
// DispatchRefreshWorkloads asks an agent to report immediately. It returns as
// soon as the owning pod acks; the caller refetches the stored document.
//
// The refresh carries nothing back on purpose: the agent answers through the
// normal ReportWorkloads RPC, so server_workloads has exactly one writer.
func DispatchRefreshWorkloads(serverID string) error {
return Dispatcher.dispatch(serverID, &pb.ServerCommand{
CommandId: uuid.New().String(),
RefreshWorkloads: &pb.RefreshWorkloadsCmd{},
})
}
// DispatchControlWorkload runs a control action and waits for the agent's
// CommandResult.
//
// Await is called BEFORE dispatch. Reversing those two lines introduces a race
// that only shows under load, on a fast agent answering into a channel nobody
// has joined yet.
func DispatchControlWorkload(serverID, kind, id, action string) error {
commandID := uuid.New().String()
results, done := WorkloadResults.AwaitCommand(commandID)
defer done()
if err := Dispatcher.dispatch(serverID, &pb.ServerCommand{
CommandId: commandID,
ControlWorkload: &pb.ControlWorkloadCmd{Kind: kind, Id: id, Action: action},
}); err != nil {
return err
}
select {
case res, ok := <-results:
if !ok || res == nil {
return fmt.Errorf("no result from agent for %s %s", action, id)
}
if !res.Success {
return fmt.Errorf("%s", res.Message)
}
return nil
case <-time.After(workloadResultTimeout):
return fmt.Errorf("timed out waiting for the agent to %s %s", action, id)
}
}
// DispatchWorkloadLogs fetches a bounded log snapshot.
//
// Await is called BEFORE dispatch, for the same reason as above.
func DispatchWorkloadLogs(serverID, kind, id string, tail int) (string, bool, error) {
commandID := uuid.New().String()
results, done := WorkloadResults.Await(commandID)
defer done()
if err := Dispatcher.dispatch(serverID, &pb.ServerCommand{
CommandId: commandID,
WorkloadLogs: &pb.WorkloadLogsCmd{Kind: kind, Id: id, Tail: int32(tail)},
}); err != nil {
return "", false, err
}
select {
case res, ok := <-results:
if !ok || res == nil {
return "", false, fmt.Errorf("no log result from agent for %s", id)
}
if res.Error != "" {
return "", false, fmt.Errorf("%s", res.Error)
}
return res.Text, res.Truncated, nil
case <-time.After(workloadResultTimeout):
return "", false, fmt.Errorf("timed out waiting for logs for %s", id)
}
}
+14 -20
View File
@@ -21,6 +21,7 @@ const COMPARISON: [string, string, string, string][] = [
["Audit history", "30 days", "365 days", "Forever"],
["Browser console", "Not Available", "Add-on", "Add-on"],
["Single sign-on", "Not Available", "Add-on", "Add-on"],
["Vulnerability scanning", "Not Available", "Add-on", "Add-on"],
["Support", "Community", "Email, 24/5", "Email and phone, 24/7"],
["Cloud term", "Annual, £0", "Monthly or annual", "Monthly or annual"],
["Self-hosted term", "Annual, £0", "Annual", "Annual"],
@@ -39,10 +40,7 @@ export default function PricingPage() {
>
Pick a tier, then pay per server.
</h1>
<p className="lede">
You pay for the number of servers you manage, and nothing else. Adding people costs nothing, and neither does adding keys, scripts, uptime checks or stored passwords. Every tier costs
the same whether we host it or you do.
</p>
<p className="lede">You pay for the number of servers you manage, and nothing else. Adding people costs nothing, and neither does adding keys, scripts, uptime checks or stored passwords. Every tier costs the same whether we host it or you do.</p>
<div className="plans">
<div className="plan">
@@ -79,7 +77,7 @@ export default function PricingPage() {
<li>3 servers included, add as many as you like</li>
<li>Unlimited monitors, secrets and channels</li>
<li>365 days of audit history</li>
<li>Browser console and single sign-on as add-ons</li>
<li>Browser console, single sign-on and vulnerability scanning as add-ons</li>
<li>Email support, 24/5</li>
</ul>
<Link className="btn btn--solid" href="/start">
@@ -125,6 +123,13 @@ export default function PricingPage() {
<p>Connect your own OIDC provider to an instance. £90 a year.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">£7 / MO</span>
<div>
<h3>Vulnerability Scanning</h3>
<p>Agents will scan and report known Vulnerabilites. £70 a year.</p>
</div>
</div>
</div>
<p className="scroll__hint">Scroll the table sideways for all three tiers</p>
@@ -179,39 +184,28 @@ export default function PricingPage() {
<span className="spec__k">LIMITS</span>
<div>
<h3>Reaching your limit</h3>
<p>
Nothing gets deleted. A server past the limit still checks in and still shows up in your list, but access changes stop reaching it until you raise the limit or remove
one.
</p>
<p>Nothing gets deleted. A server past the limit still checks in and still shows up in your list, but access changes stop reaching it until you raise the limit or remove one.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">CANCELLING</span>
<div>
<h3>What happens when you stop paying</h3>
<p>
You keep everything until the end of the period you paid for. After that it becomes read-only: your uptime checks carry on running, your alerts still arrive and your
servers keep the access they have. You just can&apos;t change anything until you renew.
</p>
<p>You keep everything until the end of the period you paid for. After that it becomes read-only: your uptime checks carry on running, your alerts still arrive and your servers keep the access they have. You just can&apos;t change anything until you renew.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">SELF-HOSTED</span>
<div>
<h3>Why self-hosted is yearly only</h3>
<p>
When you host it yourself, your licence is a file your installation checks on its own. It never contacts us, which is the point but it also means we can&apos;t switch
one off partway through, so we sell it a year at a time.
</p>
<p>When you host it yourself, your licence is a file your installation checks on its own. It never contacts us, which is the point but it also means we can&apos;t switch one off partway through, so we sell it a year at a time.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">EXIT</span>
<div>
<h3>Leaving</h3>
<p>
You can export your servers, keys, scripts and stored passwords at any time. Your servers keep the access they already have, so nobody gets locked out while you move.
</p>
<p>You can export your servers, keys, scripts and stored passwords at any time. Your servers keep the access they already have, so nobody gets locked out while you move.</p>
</div>
</div>
</div>
+9
View File
@@ -10,6 +10,8 @@ import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { useLicense } from "@/lib/useLicense";
import { TagChips } from "@/components/servers/TagChips";
import { ServerVulnerabilities } from "@/components/vulnerabilities/ServerVulnerabilities";
import { WorkloadList } from "@/components/workloads/WorkloadList";
import { useAuth } from "@/components/AuthProvider";
function statusVariant(status: ServerStatus) {
switch (status) {
@@ -311,6 +313,9 @@ export default function ServerDetailPage() {
const [showUpdatesModal, setShowUpdatesModal] = useState(false);
const [applySuccess, setApplySuccess] = useState(false);
const { hasFeature } = useLicense();
// Control actions and log reads are owner|admin server-side; the UI matches
// so a member is not offered buttons the API will refuse.
const { isAdmin } = useAuth();
const consoleAllowed = hasFeature("console");
const {
@@ -554,6 +559,10 @@ export default function ServerDetailPage() {
<ServerVulnerabilities serverId={server.server_id} />
</div>
<div className="lg:col-span-3">
<WorkloadList serverId={server.server_id} canControl={isAdmin} />
</div>
<div className="lg:col-span-2">
<Card padding={false}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
+5 -22
View File
@@ -104,14 +104,7 @@ function Allowance({ label, used, limit }: { label: string; used: number; limit:
{unlimited ? (
<p className="mt-3 font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-secondary">No limit</p>
) : (
<div
className="mt-3 h-1.5 overflow-hidden rounded-full bg-surface-2"
role="meter"
aria-valuenow={used}
aria-valuemin={0}
aria-valuemax={limit}
aria-label={`${label}: ${used} of ${limit} used`}
>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-surface-2" role="meter" aria-valuenow={used} aria-valuemin={0} aria-valuemax={limit} aria-label={`${label}: ${used} of ${limit} used`}>
<div className={`h-full rounded-full ${fill}`} style={{ width: `${pct}%` }} />
</div>
)}
@@ -122,12 +115,7 @@ function Allowance({ label, used, limit }: { label: string; used: number; limit:
function Feature({ label, included }: { label: string; included: boolean }) {
return (
<div className="flex items-center gap-3 py-2.5">
<span
aria-hidden
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm border text-[0.6rem] font-bold ${
included ? "border-success text-success" : "border-border text-text-tertiary"
}`}
>
<span aria-hidden className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm border text-[0.6rem] font-bold ${included ? "border-success text-success" : "border-border text-text-tertiary"}`}>
{included ? "✓" : ""}
</span>
<span className="text-sm text-text-primary">{label}</span>
@@ -183,11 +171,7 @@ function RecordPanel({ license }: { license: LicenseInfo }) {
<Keyed label="Instance ID">
<div className="flex items-center gap-2">
<code className="truncate font-mono text-xs text-text-primary">{license.instance_id}</code>
<button
type="button"
onClick={copyId}
className="flex-shrink-0 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[0.6rem] uppercase tracking-[0.1em] text-text-secondary transition-colors hover:border-text-tertiary hover:text-text-primary"
>
<button type="button" onClick={copyId} className="flex-shrink-0 rounded-sm border border-border px-1.5 py-0.5 font-mono text-[0.6rem] uppercase tracking-[0.1em] text-text-secondary transition-colors hover:border-text-tertiary hover:text-text-primary">
{copied ? "Copied" : "Copy"}
</button>
</div>
@@ -265,6 +249,7 @@ export default function LicensePage() {
<div className="divide-y divide-border-soft">
<Feature label="Browser console" included={Boolean(license.features.console)} />
<Feature label="Single sign-on" included={Boolean(license.features.oidc)} />
<Feature label="Vulnerability Scanning" included={Boolean(license.features.vuln_scanning)} />
</div>
</Card>
</Group>
@@ -272,9 +257,7 @@ export default function LicensePage() {
{isCloud ? (
<Group label="Where this licence comes from">
<Card>
<p className="max-w-prose text-sm text-text-secondary">
This is a cloud instance, so its licence is issued and renewed in Vantage HQ and applied here automatically. There is nothing to paste.
</p>
<p className="max-w-prose text-sm text-text-secondary">This is a cloud instance, so its licence is issued and renewed in Vantage HQ and applied here automatically. There is nothing to paste.</p>
{hqUrl && (
<div className="mt-5 border-t border-border-soft pt-5">
<a href={hqUrl} target="_blank" rel="noreferrer">
+63 -43
View File
@@ -4,19 +4,24 @@ import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, vulnerabilities, type FindingState, type Severity, type VulnFinding } from "@/lib/api";
import { useAuth } from "@/components/AuthProvider";
import { Button } from "@/components/ui";
import { Button, Card } from "@/components/ui";
import { AcceptDialog } from "@/components/vulnerabilities/AcceptDialog";
import { DBFreshness } from "@/components/vulnerabilities/DBFreshness";
import { FindingRow } from "@/components/vulnerabilities/FindingRow";
import { PackageRow } from "@/components/vulnerabilities/PackageRow";
import { SEVERITY_ORDER, SeverityBadge } from "@/components/vulnerabilities/SeverityVisuals";
import { groupByPackage } from "@/lib/vulnPackages";
/*
* The fleet vulnerability board.
*
* Grouped by CVE, defaulting to open findings, with database freshness always
* on screen. The three things this page must never do: imply freshness it does
* not have, present an unsupported distribution as clean, or make one CVE on
* forty servers look like forty problems.
* Grouped by package, defaulting to open findings, with database freshness
* always on screen. The three things this page must never do: imply freshness
* it does not have, present an unsupported distribution as clean, or make one
* upgrade look like several problems.
*
* The API groups by CVE; the rollup to packages happens here, in
* `lib/vulnPackages`, because a finding is still per-CVE everywhere it is
* stored, accepted or remediated.
*/
const STATES: FindingState[] = ["open", "accepted", "fixed"];
@@ -41,6 +46,8 @@ export default function VulnerabilitiesPage() {
const servers = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const packages = useMemo(() => groupByPackage(groups.data ?? []), [groups.data]);
const serverName = useMemo(() => {
const byId = new Map((servers.data ?? []).map((s) => [s.server_id, s.hostname]));
// Falls back to the raw id rather than an empty cell: an unnamed row is
@@ -78,12 +85,12 @@ export default function VulnerabilitiesPage() {
const total = SEVERITY_ORDER.reduce((n, s) => n + (counts[s] ?? 0), 0);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold text-text-primary">Vulnerabilities</h1>
<h1 className="text-2xl font-bold text-text-primary">Vulnerabilities</h1>
<p className="mt-1 text-sm text-text-secondary">
Installed packages matched against distribution security advisories.
{total} open finding{total !== 1 ? "s" : ""} · installed packages matched against distribution security advisories
</p>
</div>
{isAdmin && (
@@ -93,32 +100,36 @@ export default function VulnerabilitiesPage() {
)}
</div>
<DBFreshness summary={summary.data} />
<div className="mb-4">
<DBFreshness summary={summary.data} />
</div>
<div className="flex flex-wrap gap-4 rounded-lg border border-border bg-surface px-5 py-4">
{/* Severity counts double as the filter. They are always the whole
fleet's open counts, never the filtered view's, so switching
state cannot make the fleet look better than it is. */}
<div className="mb-4 flex flex-wrap gap-2 rounded-lg border border-border bg-surface px-4 py-3 sm:px-5">
{SEVERITY_ORDER.map((s) => (
<button
key={s}
onClick={() => setSeverity(severity === s ? "" : s)}
className={`flex items-center gap-2 rounded px-2 py-1 text-left transition-colors ${
severity === s ? "bg-surface-2" : "hover:bg-surface-2"
aria-pressed={severity === s}
className={`flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-left transition-colors ${
severity === s ? "border-accent bg-surface-2" : "border-transparent hover:bg-surface-2"
}`}
>
<SeverityBadge severity={s} />
<span className="font-mono text-lg font-semibold tabular-nums text-text-primary">{counts[s] ?? 0}</span>
</button>
))}
<span className="ml-auto self-center font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">
{total} open
</span>
</div>
<div className="flex gap-2">
<div className="mb-4 flex gap-2">
{STATES.map((s) => (
<button
key={s}
onClick={() => setState(s)}
className={`rounded border px-3 py-1.5 text-sm capitalize transition-colors ${
aria-pressed={state === s}
className={`rounded-lg border px-3 py-1.5 text-sm capitalize transition-colors ${
state === s ? "border-accent text-accent" : "border-border text-text-secondary hover:text-text-primary"
}`}
>
@@ -127,33 +138,42 @@ export default function VulnerabilitiesPage() {
))}
</div>
{groups.isLoading && <p className="text-sm text-text-secondary">Loading</p>}
{groups.error && <p className="text-sm text-danger">{(groups.error as Error).message}</p>}
{groups.data && groups.data.length === 0 && (
<div className="rounded-lg border border-border bg-surface px-5 py-8 text-center">
<p className="text-sm text-text-secondary">No {state} findings.</p>
<p className="mt-1 text-xs text-text-tertiary">
Servers report packages hourly. A server whose distribution has no advisory feed is reported as unsupported on its own
page rather than counted here.
</p>
{groups.error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(groups.error as Error).message}
</div>
)}
<div className="space-y-2">
{groups.data?.map((g) => (
<FindingRow
key={g.cve_id}
group={g}
serverName={serverName}
canAct={isAdmin}
onAccept={setAccepting}
onUnaccept={(f) => unaccept.mutate(f.id)}
onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)}
applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined}
/>
))}
</div>
<Card padding={false}>
{groups.isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : packages.length > 0 ? (
packages.map((g) => (
<PackageRow
key={g.package_name}
group={g}
serverName={serverName}
canAct={isAdmin}
onAccept={setAccepting}
onUnaccept={(f) => unaccept.mutate(f.id)}
onApplyUpdates={(serverId) => applyUpdates.mutate(serverId)}
applying={applyUpdates.isPending ? (applyUpdates.variables as string) : undefined}
/>
))
) : (
<div className="px-6 py-14 text-center">
<p className="text-[15px] font-semibold text-text-primary">
No {state} findings{severity ? ` at ${severity} severity` : ""}.
</p>
<p className="mx-auto mt-2 max-w-[52ch] text-sm text-text-secondary">
Servers report their packages hourly. A server whose distribution has no advisory feed is reported as unsupported
on its own page rather than counted as clean here.
</p>
</div>
)}
</Card>
{accepting && (
<AcceptDialog
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Badge, Button, Card, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { api, workloads } from "@/lib/api";
/*
* The fleet view answers "which servers run image X", which is the reason the
* snapshot is stored at all rather than fetched on demand and discarded.
*/
export default function WorkloadsPage() {
const [image, setImage] = useState("");
const [stack, setStack] = useState("");
const [state, setState] = useState("");
const [applied, setApplied] = useState<{ image?: string; stack?: string; state?: string }>({});
const hits = useQuery({
queryKey: ["workloads", "fleet", applied],
queryFn: () => workloads.search(applied),
});
const servers = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
const hostnames = useMemo(() => {
const m = new Map<string, string>();
for (const s of servers.data ?? []) m.set(s.server_id, s.hostname);
return m;
}, [servers.data]);
const inputClass =
"w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent focus:outline-none";
return (
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Workloads</h1>
<p className="mt-1 text-sm text-text-secondary">Containers and systemd services across the fleet, as last reported by each agent.</p>
</div>
<Card className="mb-6">
<form
className="grid gap-3 sm:grid-cols-4"
onSubmit={(e) => {
e.preventDefault();
setApplied({ image: image.trim(), stack: stack.trim(), state: state.trim() });
}}
>
<input className={inputClass} placeholder="image (exact)" value={image} onChange={(e) => setImage(e.target.value)} />
<input className={inputClass} placeholder="stack" value={stack} onChange={(e) => setStack(e.target.value)} />
<input className={inputClass} placeholder="state" value={state} onChange={(e) => setState(e.target.value)} />
<Button type="submit" variant="primary">
Search
</Button>
</form>
</Card>
<Card padding={false}>
{hits.isLoading ? (
<p className="px-6 py-5 text-sm text-text-secondary">Loading</p>
) : (hits.data ?? []).length === 0 ? (
<p className="px-6 py-5 text-sm text-text-secondary">No workloads match.</p>
) : (
<Table>
<Thead>
<Tr>
<Th>Server</Th>
<Th>Workload</Th>
<Th>Kind</Th>
<Th>State</Th>
<Th>Image</Th>
<Th>Stack</Th>
</Tr>
</Thead>
<Tbody>
{(hits.data ?? []).map((h) => (
<Tr key={`${h.server_id}:${h.workload.kind}:${h.workload.id}`}>
<Td>
<Link href={`/servers/${h.server_id}`} className="text-accent hover:underline">
{hostnames.get(h.server_id) ?? h.server_id}
</Link>
</Td>
<Td className="font-mono text-xs">{h.workload.name}</Td>
<Td>
<Badge variant="neutral">{h.workload.kind}</Badge>
</Td>
<Td>{h.workload.state}</Td>
<Td className="font-mono text-xs">{h.workload.image ?? "—"}</Td>
<Td>{h.workload.stack ?? "—"}</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
</div>
);
}
+13
View File
@@ -133,10 +133,23 @@ function ShieldIcon() {
);
}
function WorkloadIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
/>
</svg>
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
{ href: "/workloads", label: "Workloads", icon: <WorkloadIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
@@ -1,104 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui";
import type { VulnFinding, VulnGroup } from "@/lib/api";
import { SeverityBadge, StateBadge, relativeTime } from "./SeverityVisuals";
/*
* One row per CVE, expandable to the servers it affects.
*
* The grouping is the point. The same CVE across forty servers is one decision
* patch it, or accept it and say why and a flat list of forty findings
* makes it look like forty decisions, which is how a board stops being read.
*/
interface Props {
group: VulnGroup;
serverName: (serverId: string) => string;
canAct: boolean;
onAccept: (f: VulnFinding) => void;
onUnaccept: (f: VulnFinding) => void;
onApplyUpdates: (serverId: string) => void;
applying?: string;
}
export function FindingRow({ group, serverName, canAct, onAccept, onUnaccept, onApplyUpdates, applying }: Props) {
const [open, setOpen] = useState(false);
// A CVE with no fix anywhere cannot be patched, only accepted. Saying so on
// the collapsed row saves opening it to find there is nothing to do.
const anyFix = group.findings.some((f) => f.fixed_in);
return (
<div className="rounded-lg border border-border bg-surface">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-surface-2"
aria-expanded={open}
>
<span className="font-mono text-xs text-text-tertiary">{open ? "▾" : "▸"}</span>
<SeverityBadge severity={group.severity} />
<span className="font-mono text-sm font-medium text-text-primary">{group.cve_id}</span>
{group.title && <span className="hidden truncate text-sm text-text-secondary sm:block">{group.title}</span>}
<span className="ml-auto whitespace-nowrap text-xs text-text-secondary">
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
</span>
{!anyFix && <span className="whitespace-nowrap text-xs text-text-tertiary">no fix published</span>}
</button>
{open && (
<div className="border-t border-border">
{group.findings.map((f) => (
<div key={f.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border-soft px-4 py-3 last:border-b-0">
<Link href={`/servers/${f.server_id}`} className="text-sm text-accent hover:underline">
{serverName(f.server_id)}
</Link>
<span className="font-mono text-xs text-text-secondary">
{f.package_name} {f.installed_version}
</span>
<span className="font-mono text-xs text-text-tertiary">
{f.fixed_in ? `${f.fixed_in}` : "no fix published"}
</span>
<StateBadge state={f.state} />
{f.state === "accepted" && f.accepted && (
<span className="text-xs text-text-tertiary">
{f.accepted.reason} · reopens {new Date(f.accepted.until).toLocaleDateString()}
</span>
)}
{f.state !== "accepted" && <span className="text-xs text-text-tertiary">first seen {relativeTime(f.first_seen)}</span>}
{canAct && (
<div className="ml-auto flex gap-2">
{/* Remediation is the existing endpoint, not a new
mechanism: see it, patch it, one place. */}
{f.fixed_in && f.state !== "fixed" && (
<Button size="sm" variant="secondary" loading={applying === f.server_id} onClick={() => onApplyUpdates(f.server_id)}>
Apply updates
</Button>
)}
{f.state === "accepted" ? (
<Button size="sm" variant="ghost" onClick={() => onUnaccept(f)}>
Reopen
</Button>
) : (
f.state === "open" && (
<Button size="sm" variant="ghost" onClick={() => onAccept(f)}>
Accept
</Button>
)
)}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui";
import type { VulnFinding } from "@/lib/api";
import type { PackageGroup, PackageServer } from "@/lib/vulnPackages";
import { SeverityBadge, StateBadge, relativeTime } from "./SeverityVisuals";
/*
* One row per package, expandable to the servers carrying it and the CVEs on
* each.
*
* The grouping is the point, and it is the same argument as the CVE grouping it
* replaced, one level in: an operator upgrades a package, not a CVE. Two CVEs
* on one apache2 are one upgrade to the higher of the two fix versions, and
* showing them as two rows with two different targets is how a fleet gets
* patched to the lower one.
*/
interface Props {
group: PackageGroup;
serverName: (serverId: string) => string;
canAct: boolean;
onAccept: (f: VulnFinding) => void;
onUnaccept: (f: VulnFinding) => void;
onApplyUpdates: (serverId: string) => void;
applying?: string;
}
export function PackageRow({ group, serverName, canAct, onAccept, onUnaccept, onApplyUpdates, applying }: Props) {
const [open, setOpen] = useState(false);
// A package with no fix anywhere cannot be patched, only accepted. Saying so
// on the collapsed row saves opening it to find there is nothing to do.
const anyFix = group.servers.some((s) => s.target);
return (
// A row inside the page's one bordered container, not a card of its
// own — the same stack idiom as the monitors and workflows lists.
<div className="border-t border-border-soft first:border-t-0">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-surface-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5"
aria-expanded={open}
>
<span className="font-mono text-xs text-text-tertiary">{open ? "▾" : "▸"}</span>
<SeverityBadge severity={group.severity} />
<span className="font-mono text-sm font-medium text-text-primary">{group.package_name}</span>
<span className="whitespace-nowrap font-mono text-[11px] text-text-tertiary">
{group.cve_count} {group.cve_count === 1 ? "CVE" : "CVEs"}
</span>
<span className="ml-auto whitespace-nowrap font-mono text-[11px] text-text-tertiary">
{group.server_count} {group.server_count === 1 ? "server" : "servers"}
</span>
{!anyFix && (
<span className="hidden whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary sm:block">
no fix
</span>
)}
</button>
{open && (
<div className="border-t border-border-soft bg-surface-2/40">
{group.servers.map((s) => (
<ServerBlock
key={`${s.package_name}:${s.server_id}`}
row={s}
serverName={serverName}
canAct={canAct}
onAccept={onAccept}
onUnaccept={onUnaccept}
onApplyUpdates={onApplyUpdates}
applying={applying}
/>
))}
</div>
)}
</div>
);
}
function ServerBlock({
row,
serverName,
canAct,
onAccept,
onUnaccept,
onApplyUpdates,
applying,
}: Omit<Props, "group"> & { row: PackageServer }) {
const patchable = row.target && row.findings.some((f) => f.state !== "fixed");
return (
<div className="border-b border-border-soft px-4 py-3 last:border-b-0 sm:px-5">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<Link href={`/servers/${row.server_id}`} className="text-sm text-accent hover:underline">
{serverName(row.server_id)}
</Link>
<span className="font-mono text-xs text-text-secondary">{row.installed_version}</span>
{/* One target, the highest across every CVE on this package.
A lower fix version does not remediate a higher one, so it
is never the number offered. */}
<span className="font-mono text-xs text-text-tertiary">{row.target ? `${row.target}` : "no fix published"}</span>
{row.superseded && (
<span
className="whitespace-nowrap rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary"
title="Several CVEs name different fix versions; the highest is shown and covers the rest."
>
supersedes lower fixes
</span>
)}
{canAct && patchable && (
<div className="ml-auto">
{/* Remediation is the existing endpoint, not a new
mechanism: see it, patch it, one place. */}
<Button size="sm" variant="secondary" loading={applying === row.server_id} onClick={() => onApplyUpdates(row.server_id)}>
Apply updates
</Button>
</div>
)}
</div>
<div className="mt-2 space-y-1.5">
{row.findings.map((f) => (
<div key={f.id} className="flex flex-wrap items-center gap-x-3 gap-y-1.5 pl-1">
<SeverityBadge severity={f.severity} />
<span className="font-mono text-xs text-text-secondary">{f.cve_id}</span>
{f.title && <span className="hidden truncate text-xs text-text-tertiary sm:block">{f.title}</span>}
{/* The per-CVE fix stays visible when it differs from
the row's target, so the rollup can be checked
rather than taken on trust. */}
{f.fixed_in && f.fixed_in !== row.target && (
<span className="font-mono text-[11px] text-text-tertiary">fixed in {f.fixed_in}</span>
)}
{!f.fixed_in && <span className="font-mono text-[11px] text-text-tertiary">no fix</span>}
<StateBadge state={f.state} />
{f.state === "accepted" && f.accepted && (
<span className="text-xs text-text-tertiary">
{f.accepted.reason} · reopens {new Date(f.accepted.until).toLocaleDateString()}
</span>
)}
{f.state !== "accepted" && <span className="text-xs text-text-tertiary">first seen {relativeTime(f.first_seen)}</span>}
{canAct && (
<div className="ml-auto">
{f.state === "accepted" ? (
<Button size="sm" variant="ghost" onClick={() => onUnaccept(f)}>
Reopen
</Button>
) : (
f.state === "open" && (
<Button size="sm" variant="ghost" onClick={() => onAccept(f)}>
Accept
</Button>
)
)}
</div>
)}
</div>
))}
</div>
</div>
);
}
@@ -43,7 +43,7 @@ export function ServerVulnerabilities({ serverId }: { serverId: string }) {
<h2 className="text-lg font-semibold text-text-primary">Vulnerabilities</h2>
<Link href="/vulnerabilities">
<Button variant="ghost" size="sm">
Fleet board
View All
</Button>
</Link>
</div>
@@ -52,18 +52,13 @@ export function ServerVulnerabilities({ serverId }: { serverId: string }) {
{packages.isLoading || findings.isLoading ? (
<p className="text-sm text-text-secondary">Loading</p>
) : !pkg ? (
<p className="text-sm text-text-secondary">
No package inventory yet. Agents report hourly, and only when vulnerability scanning is included in this instance&apos;s
licence.
</p>
<p className="text-sm text-text-secondary">No package inventory yet. Agents report hourly, and only when vulnerability scanning is included in this instance&apos;s licence.</p>
) : pkg.status === "unsupported" ? (
<>
<p className="text-sm text-warning">
{pkg.os.family} {pkg.os.version_id} has no advisory feed, so this server cannot be scanned.
</p>
<p className="mt-1 text-xs text-text-tertiary">
This is not the same as having no vulnerabilities it means we cannot answer the question for this distribution.
</p>
<p className="mt-1 text-xs text-text-tertiary">This is not the same as having no vulnerabilities it means we cannot answer the question for this distribution.</p>
</>
) : (
<>
@@ -94,15 +89,11 @@ export function ServerVulnerabilities({ serverId }: { serverId: string }) {
<span className="font-mono text-xs text-text-secondary">
{f.package_name} {f.installed_version}
</span>
<span className="font-mono text-xs text-text-tertiary">
{f.fixed_in ? `${f.fixed_in}` : "no fix published"}
</span>
<span className="font-mono text-xs text-text-tertiary">{f.fixed_in ? `${f.fixed_in}` : "no fix published"}</span>
<StateBadge state={f.state} />
</li>
))}
{open.length > 10 && (
<li className="pt-1 text-xs text-text-tertiary">and {open.length - 10} more on the fleet board.</li>
)}
{open.length > 10 && <li className="pt-1 text-xs text-text-tertiary">and {open.length - 10} more on the fleet board.</li>}
</ul>
)}
</>
+61
View File
@@ -0,0 +1,61 @@
"use client";
import { useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { Modal } from "@/components/ui";
import { workloads, type WorkloadKind } from "@/lib/api";
/*
* A bounded snapshot, not a follow. The browser console already offers a real
* terminal on the same server where `docker logs -f` works properly, with its
* own scrollback and cancellation.
*/
export function LogDialog({
serverId,
kind,
id,
name,
onClose,
}: {
serverId: string;
kind: WorkloadKind;
id: string;
name: string;
onClose: () => void;
}) {
const pre = useRef<HTMLPreElement>(null);
const logs = useQuery({
queryKey: ["workload-logs", serverId, kind, id],
queryFn: () => workloads.logs(serverId, kind, id),
refetchOnWindowFocus: false,
});
// Newest output is the point of the snapshot, so it opens at the bottom.
useEffect(() => {
if (logs.data && pre.current) pre.current.scrollTop = pre.current.scrollHeight;
}, [logs.data]);
return (
<Modal open title={`Logs · ${name}`} onClose={onClose} wide>
{logs.isLoading && <p className="text-sm text-text-secondary">Reading logs from the agent</p>}
{logs.isError && <p className="text-sm text-danger">{(logs.error as Error).message}</p>}
{logs.data && (
<>
{/* Stated, not implied: a truncated log must never be read as
a complete one. */}
{logs.data.truncated && (
<p className="mb-3 rounded border border-warning/50 px-3 py-2 text-xs text-warning">
Output was capped at 500 lines or 256KB, whichever came first. Older lines are not shown.
</p>
)}
<pre ref={pre} className="max-h-[55dvh] overflow-auto rounded border border-border bg-well p-3 font-mono text-xs text-text-primary">
{logs.data.text || "(no output)"}
</pre>
</>
)}
</Modal>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button, Card } from "@/components/ui";
import { workloads, type Workload, type WorkloadAction, type WorkloadKind } from "@/lib/api";
import { WorkloadRow } from "./WorkloadRow";
import { LogDialog } from "./LogDialog";
function relativeAge(iso?: string): string {
if (!iso) return "never";
const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
if (secs < 60) return `${Math.round(secs)}s ago`;
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
return `${Math.round(secs / 3600)}h ago`;
}
/** Compose stacks first, grouped under the stack name; then loose containers;
* then units. Not cosmetic: a stack is one thing to an operator even when it
* is six containers, and a flat list turns one decision into six rows. */
function group(list: Workload[]) {
const stacks = new Map<string, Workload[]>();
const loose: Workload[] = [];
const units: Workload[] = [];
for (const w of list) {
if (w.kind === "unit") units.push(w);
else if (w.stack) stacks.set(w.stack, [...(stacks.get(w.stack) ?? []), w]);
else loose.push(w);
}
const byName = (a: Workload, b: Workload) => a.name.localeCompare(b.name);
return {
stacks: [...stacks.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([name, items]) => [name, items.sort(byName)] as const),
loose: loose.sort(byName),
units: units.sort(byName),
};
}
export function WorkloadList({ serverId, canControl }: { serverId: string; canControl: boolean }) {
const qc = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [logTarget, setLogTarget] = useState<Workload | null>(null);
const snapshot = useQuery({
queryKey: ["workloads", serverId],
queryFn: () => workloads.forServer(serverId),
});
const refresh = useMutation({
mutationFn: () => workloads.refresh(serverId),
// The refresh returns no data — the agent reports through the normal
// path, so the only correct move is to refetch the stored document.
onSuccess: () => {
setError(null);
setTimeout(() => qc.invalidateQueries({ queryKey: ["workloads", serverId] }), 1500);
},
onError: (e: Error) => setError(e.message),
});
const control = useMutation({
mutationFn: ({ w, action }: { w: Workload; action: WorkloadAction }) => workloads.control(serverId, w.kind as WorkloadKind, w.id, action),
onSuccess: () => {
setError(null);
qc.invalidateQueries({ queryKey: ["workloads", serverId] });
},
onError: (e: Error) => setError(e.message),
});
// Opening the panel asks for a fresh list: this page carries a Restart
// button, and a stale row is a wrong action aimed at something already dead.
useEffect(() => {
refresh.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [serverId]);
const data = snapshot.data;
const grouped = useMemo(() => group(data?.workloads ?? []), [data]);
const row = (w: Workload) => (
<WorkloadRow
key={`${w.kind}:${w.id}`}
workload={w}
canControl={canControl}
busy={control.isPending}
onAction={(action) => control.mutate({ w, action })}
onLogs={() => setLogTarget(w)}
/>
);
return (
<Card padding={false}>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-text-primary">Workloads</h2>
<p className="mt-0.5 text-xs text-text-secondary">Collected {relativeAge(data?.collected_at)}</p>
</div>
<Button variant="ghost" size="sm" loading={refresh.isPending} onClick={() => refresh.mutate()}>
Refresh
</Button>
</div>
<div className="px-6 py-4">
{error && <p className="mb-3 text-sm text-danger">{error}</p>}
{snapshot.isLoading ? (
<p className="text-sm text-text-secondary">Loading</p>
) : !data ? (
<p className="text-sm text-text-secondary">Nothing reported yet. Agents report every 60 seconds, on Linux only.</p>
) : (
<div className="space-y-2 text-sm">
{/* Docker absent is the common case on a fleet built
around SSH keys, and is not a fault. Installed but
not responding is a different problem, so it reads
differently. */}
{data.docker_error ? (
<p className="text-warning">Docker is installed but not responding: {data.docker_error}</p>
) : !data.docker_ok ? (
<p className="text-text-secondary">Docker is not in use on this server.</p>
) : null}
{data.systemd_error ? (
<p className="text-warning">systemd could not be read: {data.systemd_error}</p>
) : !data.systemd_ok ? (
<p className="text-text-secondary">systemd is not in use on this server.</p>
) : null}
</div>
)}
</div>
{grouped.stacks.map(([stack, items]) => (
<div key={stack}>
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">
stack · {stack}
</div>
{items.map(row)}
</div>
))}
{grouped.loose.length > 0 && (
<div>
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">containers</div>
{grouped.loose.map(row)}
</div>
)}
{grouped.units.length > 0 && (
<div>
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">services</div>
{grouped.units.map(row)}
</div>
)}
{logTarget && (
<LogDialog
serverId={serverId}
kind={logTarget.kind as WorkloadKind}
id={logTarget.id}
name={logTarget.name}
onClose={() => setLogTarget(null)}
/>
)}
</Card>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { Badge, Button } from "@/components/ui";
import type { Workload, WorkloadAction } from "@/lib/api";
/*
* Container and unit states are kept in their own vocabularies on purpose a
* failed unit and an exited container mean different things. Colour never
* carries the state on its own: Badge already pairs a dot with the word.
*/
function stateVariant(w: Workload): "success" | "warning" | "danger" | "neutral" {
if (w.kind === "container") {
switch (w.state) {
case "running":
return w.health === "unhealthy" ? "danger" : "success";
case "restarting":
case "paused":
case "created":
return "warning";
case "dead":
case "exited":
return "danger";
default:
return "neutral";
}
}
switch (w.state) {
case "active":
return "success";
case "activating":
case "reloading":
return "warning";
case "failed":
return "danger";
case "inactive":
return "neutral";
default:
return "neutral";
}
}
const ACTIONS: WorkloadAction[] = ["start", "stop", "restart"];
export function WorkloadRow({
workload,
canControl,
busy,
onAction,
onLogs,
}: {
workload: Workload;
canControl: boolean;
busy: boolean;
onAction: (action: WorkloadAction) => void;
onLogs: () => void;
}) {
const w = workload;
return (
<div className="flex flex-col gap-3 border-b border-border px-6 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-mono text-sm text-text-primary">{w.name}</span>
<Badge variant={stateVariant(w)}>{w.state}</Badge>
{w.health && <Badge variant={w.health === "healthy" ? "success" : "warning"}>{w.health}</Badge>}
{!!w.restarts && w.restarts > 0 && <Badge variant="warning">{w.restarts} restarts</Badge>}
</div>
<p className="mt-1 truncate text-xs text-text-secondary">
{w.kind === "container" ? w.image || "no image" : "systemd unit"}
{w.ports && w.ports.length > 0 && <span className="ml-2 font-mono">{w.ports.join(" ")}</span>}
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{canControl &&
ACTIONS.map((a) => (
<Button
key={a}
size="sm"
variant={a === "stop" ? "danger" : "secondary"}
/* Protected rows show the action disabled with the
reason rather than offering a button whose refusal
the agent has already told us about. */
disabled={w.protected || busy}
title={w.protected ? "This workload runs the Vantage agent and cannot be controlled from here" : undefined}
onClick={() => onAction(a)}
>
{a}
</Button>
))}
{canControl && (
<Button size="sm" variant="ghost" onClick={onLogs}>
Logs
</Button>
)}
</div>
</div>
);
}
+73
View File
@@ -1054,6 +1054,79 @@ export const vulnerabilities = {
},
};
export type WorkloadKind = "container" | "unit";
export type WorkloadAction = "start" | "stop" | "restart";
/** One container or one systemd unit.
*
* `state` is deliberately not a shared vocabulary across the two kinds:
* containers report running/exited/paused/restarting/created, units report
* active/inactive/failed/activating. A failed unit and an exited container
* mean different things. */
export interface Workload {
kind: WorkloadKind;
id: string;
name: string;
state: string;
health?: string;
image?: string;
stack?: string;
ports?: string[];
restarts?: number;
started_at?: string;
protected: boolean;
}
export interface ServerWorkloads {
server_id: string;
hash?: string;
workloads: Workload[];
collected_at?: string;
/** false with no error means "Docker not in use here", which is not a
* fault. With an error it means installed but not responding. */
docker_ok: boolean;
docker_error?: string;
systemd_ok: boolean;
systemd_error?: string;
}
export interface WorkloadHit {
server_id: string;
workload: Workload;
}
export const workloads = {
forServer(serverId: string): Promise<ServerWorkloads> {
return request<ServerWorkloads>(`/servers/${serverId}/workloads`);
},
refresh(serverId: string): Promise<{ message: string }> {
return request<{ message: string }>(`/servers/${serverId}/workloads/refresh`, { method: "POST" });
},
control(serverId: string, kind: WorkloadKind, id: string, action: WorkloadAction): Promise<{ message: string }> {
return request<{ message: string }>(
`/servers/${serverId}/workloads/${encodeURIComponent(id)}/action`,
{ method: "POST", body: JSON.stringify({ kind, action }) },
);
},
logs(serverId: string, kind: WorkloadKind, id: string, tail = 500): Promise<{ text: string; truncated: boolean }> {
return request<{ text: string; truncated: boolean }>(
`/servers/${serverId}/workloads/${encodeURIComponent(id)}/logs?kind=${kind}&tail=${tail}`,
);
},
search(params?: { image?: string; stack?: string; state?: string }): Promise<WorkloadHit[]> {
const q = new URLSearchParams();
if (params?.image) q.set("image", params.image);
if (params?.stack) q.set("stack", params.stack);
if (params?.state) q.set("state", params.state);
const qs = q.toString();
return request<WorkloadHit[]>(`/workloads${qs ? `?${qs}` : ""}`);
},
};
// `request` already prefixes /api, so these paths do not repeat it.
export const licence = {
get(): Promise<LicenseInfo> {
Binary file not shown.
File diff suppressed because one or more lines are too long