Compare commits

..
140 Commits
Author SHA1 Message Date
mrhid6 19b4aef95b feat(server): dark-themed HTML email notification template
Server Deploy / deploy (push) Successful in 1m22s
2026-07-21 15:14:57 +01:00
mrhid6 0464c540b2 feat: edit monitors + notification channels; HTTP monitor insecure-TLS option
Server Deploy / deploy (push) Successful in 2m9s
Agent Release / build (push) Successful in 10m39s
Agent Release / msi (push) Successful in 35s
2026-07-21 14:55:41 +01:00
mrhid6 45178d455e fix(server): SMTP dispatch dial timeout + implicit/STARTTLS handling
Server Deploy / deploy (push) Successful in 2m19s
2026-07-21 14:46:15 +01:00
mrhid6 f28ab1a741 feat(web): redesign settings page, drop legacy webhook/email alerting UI
Server Deploy / deploy (push) Successful in 1m24s
2026-07-21 14:40:36 +01:00
mrhid6 df6f8b6f62 feat(web): notification channel settings UI
Server Deploy / deploy (push) Successful in 1m24s
2026-07-21 14:24:38 +01:00
mrhid6 8f3a27100f feat(server): multi-channel monitor notifications
Server Deploy / deploy (push) Successful in 1m16s
2026-07-21 14:22:55 +01:00
mrhid6 69c7a352f6 feat(agent): agent-run monitor scheduler
Server Deploy / deploy (push) Successful in 1m21s
Agent Release / build (push) Successful in 10m43s
Agent Release / msi (push) Successful in 1m5s
2026-07-21 14:19:59 +01:00
mrhid6 a2bfa98a2d feat(proto): SyncMonitors + ReportChecks RPCs
Server Deploy / deploy (push) Successful in 1m27s
2026-07-21 14:18:49 +01:00
mrhid6 57151826ae feat(web): monitors list/detail/form UI
Server Deploy / deploy (push) Successful in 2m1s
2026-07-21 14:16:08 +01:00
mrhid6 e413009faa feat(server): server-run monitor scheduler + REST API
Server Deploy / deploy (push) Successful in 58s
2026-07-21 14:13:26 +01:00
mrhid6 3ec9f1b35f feat(server): monitor ingest pipeline, incidents, rollups
Server Deploy / deploy (push) Successful in 2m0s
2026-07-21 14:12:03 +01:00
mrhid6 e019493087 feat(server): monitor model + checker package
Server Deploy / deploy (push) Successful in 53s
2026-07-21 14:10:46 +01:00
mrhid6 ca2c05db14 feat(web): inventory panel on server detail
Server Deploy / deploy (push) Successful in 41s
2026-07-21 14:08:36 +01:00
mrhid6 1850f352a2 feat(agent): schedule inventory reporting (30s metrics, 15m static)
Server Deploy / deploy (push) Successful in 1m15s
2026-07-21 14:07:09 +01:00
mrhid6 850ffbafe1 feat(agent): /proc-based inventory collectors
Server Deploy / deploy (push) Successful in 16s
2026-07-21 14:06:11 +01:00
mrhid6 a28157dcf8 feat(server): store inventory and handle ReportInventory RPC
Server Deploy / deploy (push) Successful in 1m4s
2026-07-21 14:05:19 +01:00
mrhid6 03e2c3c50d feat(proto): add ReportInventory RPC and inventory model
Server Deploy / deploy (push) Successful in 2m38s
2026-07-21 14:04:28 +01:00
mrhid6 fb1a1292ec docs: add service monitoring phases to fleet inventory plan
Server Deploy / deploy (push) Successful in 21s
2026-07-21 14:01:50 +01:00
mrhid6 ec201a23a2 fix: Fixed draft workflow status
Server Deploy / deploy (push) Successful in 41s
2026-07-21 12:09:19 +01:00
mrhid6 d9a33b0672 fix(web): stop autosave loop by ignoring volatile server-echo fields
Server Deploy / deploy (push) Successful in 39s
2026-07-21 12:01:57 +01:00
mrhid6 2b7ef98dff fix(web): move autosave hooks above early return (React #310)
Server Deploy / deploy (push) Successful in 1m37s
2026-07-21 11:55:20 +01:00
mrhid6 b5f30bc7c8 Merge feat/step-picker-modal: autosave, step-picker modal, Steps page
Server Deploy / deploy (push) Successful in 1m22s
2026-07-21 11:51:03 +01:00
mrhid6 3a0116248e fix(web): guard autosave against in-flight lost-update race 2026-07-21 11:50:53 +01:00
mrhid6 6af0a88841 feat(web): add Steps to main nav 2026-07-21 11:45:43 +01:00
mrhid6 e4c3fc24d3 feat(web): standalone Steps management page 2026-07-21 11:43:42 +01:00
mrhid6 e46d0edbf2 feat(web): replace builder step sidebar with Add-step modal 2026-07-21 11:41:37 +01:00
mrhid6 a4c4a72dbc feat(web): StepPickerModal step picker component 2026-07-21 11:37:47 +01:00
mrhid6 67d729b360 feat(web): api.stepUsage binding 2026-07-21 11:36:03 +01:00
mrhid6 da6d825f45 fix(server): TestMain must not exit(0) before m.Run(); skip DB test individually 2026-07-21 11:34:56 +01:00
mrhid6 93423e32e6 feat(server): step usage counts endpoint 2026-07-21 11:31:05 +01:00
mrhid6 d0442291f5 feat(web): workflow builder autosave with last-saved status 2026-07-21 11:27:55 +01:00
mrhid6 6c5472760b Merge branch 'feat/adhoc-steps-import-export': ad-hoc steps, step import/export, default steps, auto-derived outputs
Server Deploy / deploy (push) Successful in 1m26s
2026-07-21 10:42:02 +01:00
mrhid6 7c4a676742 fix: inline step secrets + inline input/output display + import body limit 2026-07-21 10:40:45 +01:00
mrhid6 fbda26a188 feat(web): ad-hoc inline steps + import-to-inline in workflow editor 2026-07-21 10:34:51 +01:00
mrhid6 90ce7af769 feat(web): step export/import, sync defaults, auto outputs, default badge 2026-07-21 10:31:28 +01:00
mrhid6 b543cd1b3d feat(web): api client for step import/export/inline/defaults 2026-07-21 10:28:41 +01:00
mrhid6 15c9da1b01 feat(server): default steps seed-on-boot + admin re-sync 2026-07-21 10:25:41 +01:00
mrhid6 baa7bb239d feat(server): step import/export/parse endpoints 2026-07-21 10:22:35 +01:00
mrhid6 7342c46d99 feat(server): auto-derive outputs on save + resolve inline steps 2026-07-21 10:20:12 +01:00
mrhid6 813f9e6fef feat(server): derive declared_outputs from script + slugify 2026-07-21 10:17:50 +01:00
mrhid6 434f14ae3a feat(server): inline step ref + workflow validation 2026-07-21 10:15:52 +01:00
mrhid6 8398fd2279 docs: implementation plan for adhoc steps, import/export, defaults, auto-outputs 2026-07-21 10:11:16 +01:00
mrhid6 56f06b9eaf docs: auto-derive declared_outputs from script scan 2026-07-21 10:05:45 +01:00
mrhid6 d9d241f83b docs: design for adhoc steps, step import/export, default steps 2026-07-21 09:59:34 +01:00
mrhid6 aee910c1f8 fix: fixed variable inputs
Server Deploy / deploy (push) Successful in 1m22s
2026-07-20 18:00:47 +01:00
mrhid6 bea545e873 feat: More verbose logging on workflow logs
Agent Release / build (push) Successful in 45s
Agent Release / msi (push) Successful in 42s
Server Deploy / deploy (push) Successful in 1m52s
2026-07-20 17:35:58 +01:00
mrhid6 82d7dde5f8 fix: Fixed style on workflow run
Server Deploy / deploy (push) Successful in 42s
2026-07-20 16:44:53 +01:00
mrhid6 397016ad68 feat: Updated workflow runs page
Server Deploy / deploy (push) Successful in 1m20s
2026-07-20 16:00:41 +01:00
mrhid6 39348c9491 feat(web): live SSE log tail and log retention setting
Server Deploy / deploy (push) Successful in 1m35s
2026-07-20 15:18:36 +01:00
mrhid6 63dadf6239 feat(api): server-run log fetch and SSE stream endpoints 2026-07-20 15:18:35 +01:00
mrhid6 d905c99d32 feat(server): stream step logs to files, drop log bodies from run docs 2026-07-20 15:18:35 +01:00
mrhid6 85e1baf59a feat(server): workflow log-writer registry, retention setting, sweeper 2026-07-20 15:18:35 +01:00
mrhid6 351ad59dd8 fix(web): reseed edit-workflow modal state on open
Server Deploy / deploy (push) Successful in 1m23s
2026-07-20 14:56:14 +01:00
mrhid6 dcc901b0d2 feat(web): workflow runs list page and navigation links
Server Deploy / deploy (push) Successful in 1m29s
2026-07-20 14:52:13 +01:00
mrhid6 99bf093f00 feat(web): rebuild workflow builder — mockup styling, drag-and-drop, inputs inspector 2026-07-20 14:48:29 +01:00
mrhid6 619ccd28cb feat(web): edit-workflow modal (name/targets/delete) 2026-07-20 14:44:59 +01:00
mrhid6 f22f0a4729 feat(web): edit-base-step modal with inputs/outputs editor 2026-07-20 14:42:13 +01:00
mrhid6 78194daf5f feat(web): builder tokens, Modal primitive, input-param types 2026-07-20 14:40:07 +01:00
mrhid6 f141767fc2 feat(workflows): inject step inputs into env; update returns workflow 2026-07-20 14:37:36 +01:00
mrhid6 05cd8e154b feat(workflows): step input params model + cascade step delete 2026-07-20 14:34:50 +01:00
mrhid6 004cc03ba6 docs: add workflow builder v2 plan 2026-07-20 14:33:46 +01:00
mrhid6 236e89989f docs: add workflow builder v2 spec 2026-07-20 14:30:55 +01:00
mrhid6 b0a2de8ca1 fix: Fixed workflow style topbar
Server Deploy / deploy (push) Successful in 1m20s
2026-07-20 13:55:17 +01:00
mrhid6 e9ac7be8c3 feat: updates to workflow page
Server Deploy / deploy (push) Successful in 36s
2026-07-20 13:35:35 +01:00
mrhid6 47690c58d9 fix: Fixed workflow id
Server Deploy / deploy (push) Successful in 1m14s
2026-07-20 12:49:43 +01:00
mrhid6 5d72088837 feat(agent): stream step output chunks over CommandStream
Agent Release / build (push) Successful in 44s
Server Deploy / deploy (push) Successful in 1m51s
Agent Release / msi (push) Successful in 1m23s
2026-07-20 12:36:37 +01:00
mrhid6 7a60295bc1 feat(proto): add StepOutputChunk streaming message 2026-07-20 12:34:32 +01:00
mrhid6 b48467fb6e docs: add workflow log streaming plan 2026-07-20 12:32:57 +01:00
mrhid6 b5e828c9e8 docs: add workflow log streaming spec 2026-07-20 12:30:11 +01:00
mrhid6 98284f4387 fix(workflows): mask output env in persisted logs, preserve cancelled status, run agent step async 2026-07-20 12:01:35 +01:00
mrhid6 e35e8fc839 feat(web): workflow run detail page with live logs 2026-07-20 11:53:30 +01:00
mrhid6 2cd9bc1c89 feat(web): three-pane workflow builder 2026-07-20 11:50:00 +01:00
mrhid6 39980581b1 feat(web): workflows list page and sidebar link 2026-07-20 11:46:44 +01:00
mrhid6 6f478eb817 feat(web): workflow API client types and methods 2026-07-20 11:44:10 +01:00
mrhid6 ff3a94b888 fix(api): audit workflow run cancellation 2026-07-20 11:42:31 +01:00
mrhid6 631894084a feat(api): workflow, step, and run REST endpoints 2026-07-20 11:40:36 +01:00
mrhid6 296e0179cb feat(server): workflow runner with parallel fan-out and env threading 2026-07-20 11:37:04 +01:00
mrhid6 600126a913 feat(server): step library and workflow CRUD services 2026-07-20 11:33:33 +01:00
mrhid6 4872a26786 feat(models): add workflow, step, and run models 2026-07-20 11:31:37 +01:00
mrhid6 f0c86a3bdf feat(agent): execute RunStepCmd with WORKFLOW_ENV capture 2026-07-20 11:29:14 +01:00
mrhid6 1b286762f6 feat(server): add pending step-result registry and stream delivery 2026-07-20 11:26:35 +01:00
mrhid6 3c77c20de8 feat(proto): add RunStepCmd and StepResult messages 2026-07-20 11:24:08 +01:00
mrhid6 9e53f21746 docs: add Fleet Inventory and SaaS auth/orgs specs + plans 2026-07-20 11:16:19 +01:00
mrhid6 ad35b32f5b docs: add Server Workflows implementation plan 2026-07-20 11:05:43 +01:00
mrhid6 d20d3b08fa docs: add Server Workflows design spec 2026-07-20 10:55:21 +01:00
mrhid6 c3c58581cc fix: Fixed scale and mouse handler
Server Deploy / deploy (push) Successful in 1m38s
2026-07-20 09:49:20 +01:00
mrhid6 c558b81471 fix: Fixed keyboard disconnect
Server Deploy / deploy (push) Successful in 41s
2026-07-20 09:42:07 +01:00
mrhid6 963fa9c877 fix: Fixed mouse position on console
Server Deploy / deploy (push) Successful in 39s
2026-07-20 09:36:57 +01:00
mrhid6 a02747d02e fix: Fixed console resolution
Server Deploy / deploy (push) Successful in 44s
2026-07-20 09:30:44 +01:00
mrhid6 db5b5e173f fix: Ci and agent update
Server Deploy / deploy (push) Successful in 13s
Agent Release / build (push) Successful in 10m32s
Agent Release / msi (push) Successful in 36s
2026-07-17 16:43:57 +01:00
mrhid6 2657780e7a fix: agent msi agent version upgrade
Server Deploy / deploy (push) Successful in 11s
Agent Release / build (push) Successful in 10m38s
Agent Release / msi (push) Successful in 58s
2026-07-17 16:19:42 +01:00
mrhid6 129be23a9d fix: Fixed msi version
Server Deploy / deploy (push) Successful in 13s
Agent Release / build (push) Successful in 33s
Agent Release / msi (push) Successful in 33s
2026-07-17 16:10:19 +01:00
mrhid6 d31486ae1b fix: Fixed agent windows version
Server Deploy / deploy (push) Successful in 1m28s
Agent Release / build (push) Successful in 10m34s
Agent Release / msi (push) Successful in 57s
2026-07-17 15:38:00 +01:00
mrhid6 4f3f3601d2 fix: Fixes to setup scripts
Agent Release / build (push) Successful in 35s
Server Deploy / deploy (push) Successful in 48s
Agent Release / msi (push) Successful in 1m8s
2026-07-17 15:18:49 +01:00
mrhid6 b022722d39 fix: More agent install debugging
Server Deploy / deploy (push) Successful in 12s
Agent Release / build (push) Successful in 33s
Agent Release / msi (push) Successful in 44s
2026-07-17 15:05:34 +01:00
mrhid6 bf96dba50e fix: Fixed install ps1 script handler
Server Deploy / deploy (push) Successful in 1m28s
2026-07-17 14:45:19 +01:00
mrhid6 2b4611f7ac feat: added windows install script to ui
Server Deploy / deploy (push) Successful in 1m20s
2026-07-17 14:40:00 +01:00
mrhid6 fdbd591c73 fix: Fixed console height
Server Deploy / deploy (push) Successful in 1m31s
2026-07-17 13:49:47 +01:00
mrhid6 1989d6cd98 fix: Fixed console width
Server Deploy / deploy (push) Successful in 1m17s
2026-07-17 13:46:12 +01:00
mrhid6 763eafa4f8 fix: Fixed guac connection
Server Deploy / deploy (push) Successful in 1m22s
2026-07-17 13:40:54 +01:00
mrhid6 3dff45350b fix: Fixed server backfill existing servers
Server Deploy / deploy (push) Successful in 1m14s
2026-07-17 13:35:10 +01:00
mrhid6 40dee57688 fix: Fixed agent ci install
Server Deploy / deploy (push) Has been cancelled
Agent Release / build (push) Successful in 10m34s
Agent Release / msi (push) Successful in 1m31s
2026-07-17 13:16:50 +01:00
mrhid6 b7561ed4e5 fix: Fixed agent ci install
Server Deploy / deploy (push) Successful in 23s
Agent Release / build (push) Successful in 35s
Agent Release / msi (push) Has been cancelled
2026-07-17 13:07:45 +01:00
mrhid6 15fdf591e0 fix: Fixed agent ci install
Server Deploy / deploy (push) Successful in 25s
Agent Release / build (push) Successful in 31s
Agent Release / msi (push) Failing after 4m15s
2026-07-17 12:40:34 +01:00
mrhid6 e09a61c0af fix: Fixed agent ci install
Agent Release / build (push) Successful in 39s
Server Deploy / deploy (push) Successful in 1m28s
Agent Release / msi (push) Failing after 54s
2026-07-17 12:31:13 +01:00
mrhid6 55bae898f3 fix compile error
Server Deploy / deploy (push) Successful in 1m34s
2026-07-17 12:29:04 +01:00
mrhid6 a9d602d021 feat: harden console sessions + complete protocol support
Server Deploy / deploy (push) Failing after 1m9s
Agent Release / build (push) Successful in 1m52s
Agent Release / msi (push) Failing after 52s
- single-use session tokens (atomic ConsumeSessionToken) + user-bound tunnel (actor must match session opener)
- wire VNC end-to-end (stash/consume password, connect+tunnel, frontend password field)
- passphrase-protected SSH keys: passphrase_enc on Key model, capture on upload, decrypt + pass to guacd
2026-07-17 12:19:07 +01:00
mrhid6 2fe08ad7e9 fix: marshal MSI install properties into deferred CustomActionData 2026-07-17 11:54:33 +01:00
mrhid6 1ad5b5d6db fix: send ssh username to guacd (default root) for console SSH 2026-07-17 11:54:33 +01:00
mrhid6 50907448d2 feat: web console page with protocol + key selection
Includes web/lib/api.ts changes (console_protocols field, connectConsole
method) required for the console page to type-check and build; the task
brief's commit file list omitted this file.
2026-07-17 11:45:19 +01:00
mrhid6 0962745bbc feat: web console page with protocol + key selection 2026-07-17 11:45:10 +01:00
mrhid6 38c51e5a3e feat: vendor guacamole-common-js and console wrapper 2026-07-17 11:42:44 +01:00
mrhid6 c26f120e42 feat: dynamic windows install.ps1 endpoint 2026-07-17 11:39:54 +01:00
mrhid6 d206bb0541 ci: package windows agent as WiX MSI 2026-07-17 11:35:09 +01:00
mrhid6 42d1ec99a8 ci: build windows agent binary in release 2026-07-17 11:33:11 +01:00
mrhid6 91d33918bb feat: skip authorized_keys management on non-linux agents 2026-07-17 11:31:58 +01:00
mrhid6 efa5b36389 feat: OS-aware agent config directory 2026-07-17 11:30:40 +01:00
mrhid6 332c7760ca fix: keep RDP creds out of tunnel URL/logs via single-use encrypted stash 2026-07-17 11:28:00 +01:00
mrhid6 138f708a87 feat: console connect + guacd websocket tunnel endpoints 2026-07-17 11:24:24 +01:00
mrhid6 9ec3cbf901 feat: add wwt/guac dep and guacd service 2026-07-17 11:21:58 +01:00
mrhid6 86ce1b3ff7 feat: console session lifecycle persistence 2026-07-17 11:20:41 +01:00
mrhid6 307946d5aa feat: build guacd connection params per protocol 2026-07-17 11:18:59 +01:00
mrhid6 1967e966ce style: gofmt console.go 2026-07-17 11:17:18 +01:00
mrhid6 19b76044ff feat: signed expiring console session tokens 2026-07-17 11:16:11 +01:00
mrhid6 257e4fa89d feat: add ConsoleSession model 2026-07-17 11:14:46 +01:00
mrhid6 f06009b152 fix: populate console defaults on register when unset (setOnInsert never fired) 2026-07-17 11:12:57 +01:00
mrhid6 aeee7aeccf feat: infer os_type and default console config on register 2026-07-17 11:11:29 +01:00
mrhid6 d69ab709b2 feat: add console fields to Server model 2026-07-17 11:09:13 +01:00
domrichardson d7c90dea07 docs: Web console implementation plan 2026-07-17 11:02:21 +01:00
domrichardson 4edb3bf441 docs: Web console (Guacamole replacement) design spec 2026-07-17 10:56:43 +01:00
domrichardson 73a06227ba fix: Fixed endpoint
Server Deploy / deploy (push) Successful in 1m20s
2026-07-03 11:56:49 +01:00
domrichardson 7c30d26878 feat: Updated secret group yaml view
Server Deploy / deploy (push) Successful in 1m38s
2026-07-03 11:33:20 +01:00
domrichardson 19596ff2a3 feat: Secret management
Server Deploy / deploy (push) Successful in 1m33s
2026-07-03 10:37:43 +01:00
domrichardson c3c16083f7 feat: Audit and settings
Server Deploy / deploy (push) Successful in 1m26s
2026-06-25 11:30:26 +01:00
domrichardson e37a09ef0d feat: Servers status icon
Server Deploy / deploy (push) Successful in 2m32s
2026-06-25 10:10:43 +01:00
domrichardson 02e84ed548 feat: Updates button
Server Deploy / deploy (push) Successful in 1m25s
2026-06-25 09:51:45 +01:00
domrichardson 7e66b23ef8 fix: fixes to command stream
Agent Release / build (push) Successful in 33s
Server Deploy / deploy (push) Successful in 1m59s
2026-06-25 09:12:20 +01:00
domrichardson 5c91db0d4c feat: Added package management
Server Deploy / deploy (push) Successful in 3m44s
Agent Release / build (push) Successful in 10m21s
2026-06-24 16:31:51 +01:00
domrichardson fab87c82c6 feat: Updated brand to be vantage
Server Deploy / deploy (push) Successful in 2m10s
Agent Release / build (push) Successful in 1m12s
2026-06-24 15:48:13 +01:00
domrichardson 9494199306 fix: Fixed agent version on server page
Server Deploy / deploy (push) Successful in 1m12s
2026-06-24 14:40:03 +01:00
127 changed files with 15774 additions and 1215 deletions

No files matched your search

+71 -5
View File
@@ -32,20 +32,86 @@ jobs:
mkdir -p dist
GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/keymanager-agent-linux-amd64 ./cmd
-o dist/vantage-agent-linux-amd64 ./cmd
GOOS=linux GOARCH=arm64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/keymanager-agent-linux-arm64 ./cmd
-o dist/vantage-agent-linux-arm64 ./cmd
GOOS=windows GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/vantage-agent-windows-amd64.exe ./cmd
- name: Checksums
working-directory: agent/dist
run: sha256sum keymanager-agent-linux-amd64 keymanager-agent-linux-arm64 > checksums.txt
run: sha256sum vantage-agent-linux-amd64 vantage-agent-linux-arm64 vantage-agent-windows-amd64.exe > checksums.txt
- name: Create release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
token: ${{ secrets.RELEASE_TOKEN }}
files: |
agent/dist/keymanager-agent-linux-amd64
agent/dist/keymanager-agent-linux-arm64
agent/dist/vantage-agent-linux-amd64
agent/dist/vantage-agent-linux-arm64
agent/dist/vantage-agent-windows-amd64.exe
agent/dist/checksums.txt
msi:
needs: build
runs-on: windows-2022
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26"
cache: true
cache-dependency-path: agent/go.sum
- name: Extract version
id: version
shell: pwsh
run: |
$v = "${{ github.ref_name }}" -replace '^agent/v', ''
"VERSION=$v" | Out-File -Append $env:GITHUB_OUTPUT
# MSI ProductVersion must be numeric x.x.x.x
"MSIVERSION=$v.0" | Out-File -Append $env:GITHUB_OUTPUT
- name: Build agent exe
working-directory: agent
shell: pwsh
env:
VERSION: ${{ steps.version.outputs.VERSION }}
run: |
$env:GOOS = "windows"; $env:GOARCH = "amd64"
go build -ldflags="-s -w -X main.Version=$env:VERSION" -o ../installer/vantage-agent-windows-amd64.exe ./cmd
- name: Install WiX
shell: pwsh
run: dotnet tool install --global wix --version 5.*
- name: Build MSI
working-directory: installer
shell: pwsh
run: |
$env:PATH = "$env:PATH;$env:USERPROFILE\.dotnet\tools"
wix build vantage-agent.wxs -d Version=${{ steps.version.outputs.MSIVERSION }} -o vantage-agent.msi
(Get-FileHash vantage-agent.msi -Algorithm SHA256).Hash.ToLower() + " vantage-agent.msi" | Out-File -Encoding ascii checksums-msi.txt
- name: Attach MSI to release
working-directory: installer
shell: pwsh
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
$api = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$tag = [uri]::EscapeDataString("${{ github.ref_name }}")
$headers = @{ Authorization = "token $env:TOKEN" }
# gitea-release-action can't find a slashed tag, so append via the API directly
$rel = Invoke-RestMethod -Headers $headers -Uri "$api/releases/tags/$tag"
foreach ($f in "vantage-agent.msi", "checksums-msi.txt") {
$name = [uri]::EscapeDataString($f)
Invoke-RestMethod -Headers $headers -Method Post -InFile $f `
-ContentType "application/octet-stream" `
-Uri "$api/releases/$($rel.id)/assets?name=$name"
}
+2 -2
View File
@@ -23,13 +23,13 @@ jobs:
- name: Build and push server image
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/keymanager/server:latest"
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/server:latest"
docker build -t "$IMAGE" -f server/Dockerfile server/
docker push "$IMAGE"
- name: Build and push web image
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/keymanager/web:latest"
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/web:latest"
docker build \
--build-arg NEXT_PUBLIC_API_URL="${{ vars.API_URL }}" \
-t "$IMAGE" \
+8 -1
View File
@@ -1,4 +1,11 @@
node_modules
dist
build
.env
.env
docs
.superpowers
installer/vantage-agent-windows-amd64.exe
installer/*.msi
installer/nssm.zip
installer/checksums-msi.txt
.next
+3 -3
View File
@@ -7,8 +7,8 @@ import (
"os/signal"
"syscall"
"github.com/mrhid6/keymanager/agent/internal/config"
agentsync "github.com/mrhid6/keymanager/agent/internal/sync"
"github.com/mrhid6/vantage/agent/internal/config"
agentsync "github.com/mrhid6/vantage/agent/internal/sync"
)
var Version = "dev"
@@ -32,7 +32,7 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
log.Printf("keymanager-agent %s starting (server=%s, poll=%s)", Version, cfg.ServerURL, cfg.PollInterval)
log.Printf("vantage-agent %s starting (server=%s, poll=%s)", Version, cfg.ServerURL, cfg.PollInterval)
if err := agentsync.Run(ctx, cfg, Version); err != nil {
log.Fatalf("agent error: %v", err)
}
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/mrhid6/keymanager/agent
module github.com/mrhid6/vantage/agent
go 1.26
+226
View File
@@ -0,0 +1,226 @@
// Package checker runs service checks (http/tcp/icmp/tls) and returns a uniform
// Result. It has no dependency on models or pb so it can be duplicated verbatim
// into the agent module (agent-run monitors) — callers map their own monitor
// representation onto Spec.
package checker
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"time"
)
// Check types (mirror models.Monitor* constants).
const (
TypeHTTP = "http"
TypeTCP = "tcp"
TypeICMP = "icmp"
TypeTLS = "tls"
)
// Spec is a self-contained description of a single check.
type Spec struct {
Type string
URL string
Host string
Port int
Method string
ExpectedStatus int
Keyword string
TLSWarnDays int
Insecure bool // skip TLS certificate verification (HTTP checks)
TimeoutSec int
}
// Result is the uniform outcome of running a check.
type Result struct {
Up bool
LatencyMs int
Message string
CertExpiry *time.Time
}
func (s Spec) timeout() time.Duration {
t := s.TimeoutSec
if t <= 0 || t > 10 {
t = 10
}
return time.Duration(t) * time.Second
}
// Run executes the check described by s.
func Run(ctx context.Context, s Spec) Result {
switch s.Type {
case TypeHTTP:
return runHTTP(ctx, s)
case TypeTCP:
return runTCP(ctx, s)
case TypeICMP:
return runICMP(ctx, s)
case TypeTLS:
return runTLS(ctx, s)
default:
return Result{Message: "unknown check type: " + s.Type}
}
}
func runHTTP(ctx context.Context, s Spec) Result {
method := s.Method
if method == "" {
method = http.MethodGet
}
expect := s.ExpectedStatus
if expect == 0 {
expect = 200
}
client := &http.Client{Timeout: s.timeout()}
if s.Insecure {
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // opt-in per monitor
}
start := time.Now()
req, err := http.NewRequestWithContext(ctx, method, s.URL, nil)
if err != nil {
return Result{Message: err.Error()}
}
resp, err := client.Do(req)
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
}
defer resp.Body.Close()
res := Result{LatencyMs: msSince(start), Up: true}
if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
exp := resp.TLS.PeerCertificates[0].NotAfter
res.CertExpiry = &exp
}
if resp.StatusCode != expect {
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: fmt.Sprintf("status %d (want %d)", resp.StatusCode, expect)}
}
if s.Keyword != "" {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if !strings.Contains(string(body), s.Keyword) {
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: "keyword not found"}
}
}
return res
}
func runTCP(ctx context.Context, s Spec) Result {
addr := net.JoinHostPort(s.Host, fmt.Sprint(s.Port))
start := time.Now()
d := net.Dialer{Timeout: s.timeout()}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
}
conn.Close()
return Result{Up: true, LatencyMs: msSince(start)}
}
func runTLS(ctx context.Context, s Spec) Result {
port := s.Port
if port == 0 {
port = 443
}
addr := net.JoinHostPort(s.Host, fmt.Sprint(port))
start := time.Now()
d := net.Dialer{Timeout: s.timeout()}
conn, err := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: s.Host})
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
}
defer conn.Close()
certs := conn.ConnectionState().PeerCertificates
if len(certs) == 0 {
return Result{LatencyMs: msSince(start), Message: "no peer certificate"}
}
exp := certs[0].NotAfter
res := Result{LatencyMs: msSince(start), CertExpiry: &exp}
warn := s.TLSWarnDays
if warn <= 0 {
warn = 14
}
remaining := time.Until(exp)
if remaining <= 0 {
res.Message = "certificate expired"
return res
}
if remaining <= time.Duration(warn)*24*time.Hour {
res.Message = fmt.Sprintf("certificate expires in %d days", int(remaining.Hours()/24))
return res
}
res.Up = true
return res
}
func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) }
// runICMP sends a single ICMP echo request and waits for the reply. Requires
// raw-socket privileges (the agent and server run as root). Returns down with a
// descriptive message when the socket cannot be opened or no reply arrives.
func runICMP(ctx context.Context, s Spec) Result {
dst, err := net.ResolveIPAddr("ip4", s.Host)
if err != nil {
return Result{Message: err.Error()}
}
conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return Result{Message: "icmp socket: " + err.Error()}
}
defer conn.Close()
id := os.Getpid() & 0xffff
pkt := icmpEcho(id, 1)
deadline := time.Now().Add(s.timeout())
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
deadline = d
}
_ = conn.SetDeadline(deadline)
start := time.Now()
if _, err := conn.WriteTo(pkt, dst); err != nil {
return Result{Message: err.Error()}
}
reply := make([]byte, 1500)
for {
n, peer, err := conn.ReadFrom(reply)
if err != nil {
return Result{LatencyMs: msSince(start), Message: "no reply"}
}
// Skip the IPv4 header (20 bytes) to reach the ICMP message.
if n < 28 || peer.String() != dst.String() {
continue
}
if reply[20] == 0 { // ICMP echo reply type
return Result{Up: true, LatencyMs: msSince(start)}
}
}
}
func icmpEcho(id, seq int) []byte {
// Type(8)=echo request, Code=0, Checksum, ID, Seq, no payload.
b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)}
cs := icmpChecksum(b)
b[2] = byte(cs >> 8)
b[3] = byte(cs)
return b
}
func icmpChecksum(b []byte) uint16 {
var sum uint32
for i := 0; i < len(b)-1; i += 2 {
sum += uint32(b[i])<<8 | uint32(b[i+1])
}
if len(b)%2 == 1 {
sum += uint32(b[len(b)-1]) << 8
}
for sum>>16 != 0 {
sum = (sum & 0xffff) + (sum >> 16)
}
return ^uint16(sum)
}
+18 -4
View File
@@ -2,12 +2,26 @@ package config
import (
"os"
"path/filepath"
"runtime"
"time"
"gopkg.in/yaml.v3"
)
const ConfigPath = "/etc/keymanager/config.yaml"
// ConfigDir returns the platform-specific config directory.
func ConfigDir() string {
if runtime.GOOS == "windows" {
base := os.Getenv("ProgramData")
if base == "" {
base = `C:\ProgramData`
}
return filepath.Join(base, "vantage")
}
return "/etc/vantage"
}
func configPath() string { return filepath.Join(ConfigDir(), "config.yaml") }
type Config struct {
ServerURL string `yaml:"server_url"`
@@ -19,7 +33,7 @@ type Config struct {
}
func Load() (*Config, error) {
data, err := os.ReadFile(ConfigPath)
data, err := os.ReadFile(configPath())
if err != nil {
return nil, err
}
@@ -38,8 +52,8 @@ func Save(cfg *Config) error {
if err != nil {
return err
}
if err := os.MkdirAll("/etc/keymanager", 0700); err != nil {
if err := os.MkdirAll(ConfigDir(), 0700); err != nil {
return err
}
return os.WriteFile(ConfigPath, data, 0600)
return os.WriteFile(configPath(), data, 0600)
}
+20
View File
@@ -0,0 +1,20 @@
package config
import (
"runtime"
"strings"
"testing"
)
func TestConfigDirByOS(t *testing.T) {
d := ConfigDir()
if runtime.GOOS == "windows" {
if !strings.Contains(strings.ToLower(d), "programdata") {
t.Fatalf("windows config dir = %q, want ProgramData path", d)
}
} else {
if d != "/etc/vantage" {
t.Fatalf("unix config dir = %q, want /etc/vantage", d)
}
}
}
+164
View File
@@ -0,0 +1,164 @@
package exec
import (
"bufio"
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
)
// streamWriter forwards every write to emit() as an ordered chunk. Used as both
// Stdout and Stderr so output interleaves in real execution order. The mutex
// ensures a single stdout/stderr write is not interleaved mid-slice with another.
type streamWriter struct {
mu sync.Mutex
seq uint64
emit func(seq uint64, data []byte)
}
func (w *streamWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.emit != nil {
buf := make([]byte, len(p))
copy(buf, p)
w.emit(w.seq, buf)
w.seq++
}
return len(p), nil
}
// WorkspacePath returns the per-run working directory for a workspace id. The
// same id always maps to the same path so RunStep and the cleanup command agree.
func WorkspacePath(workspaceID string) string {
return filepath.Join(os.TempDir(), "vantage-run-"+workspaceID)
}
// RunStep writes the script to a temp file, provides a WORKFLOW_ENV file for
// the script to append KEY=value output to, executes it under the requested
// interpreter, and streams output via emit, returning the terminal result
// with empty stdout/stderr but populated exit_code/output_env.
//
// When the command carries a WorkspaceId the step runs with that per-run working
// directory as its cwd (created here if missing); the server removes it once the
// run finishes. The script and env files always live in a private temp dir so
// they never leak into the shared workspace.
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult {
res := &pb.StepResult{CommandId: "", OutputEnv: map[string]string{}}
dir, err := os.MkdirTemp("", "vantage-step-")
if err != nil {
res.ExitCode = 1
res.Stderr = "create temp dir: " + err.Error()
return res
}
defer os.RemoveAll(dir)
workDir := ""
if cmd.WorkspaceId != "" {
workDir = WorkspacePath(cmd.WorkspaceId)
if err := os.MkdirAll(workDir, 0700); err != nil {
res.ExitCode = 1
res.Stderr = "create workspace: " + err.Error()
return res
}
}
envFile := filepath.Join(dir, "workflow_env")
if err := os.WriteFile(envFile, nil, 0600); err != nil {
res.ExitCode = 1
res.Stderr = "create env file: " + err.Error()
return res
}
var scriptPath string
var c *exec.Cmd
timeout := time.Duration(cmd.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 30 * time.Minute
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
switch cmd.Interpreter {
case "powershell":
scriptPath = filepath.Join(dir, "step.ps1")
if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0600); err != nil {
res.ExitCode = 1
res.Stderr = err.Error()
return res
}
shell := "pwsh"
if runtime.GOOS == "windows" {
if _, err := exec.LookPath("pwsh"); err != nil {
shell = "powershell.exe"
}
}
c = exec.CommandContext(ctx, shell, "-NoProfile", "-NonInteractive", "-File", scriptPath)
default: // "bash"
scriptPath = filepath.Join(dir, "step.sh")
if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0700); err != nil {
res.ExitCode = 1
res.Stderr = err.Error()
return res
}
c = exec.CommandContext(ctx, "bash", scriptPath)
}
if workDir != "" {
c.Dir = workDir
}
c.Env = append(os.Environ(), "WORKFLOW_ENV="+envFile)
for k, v := range cmd.Env {
c.Env = append(c.Env, k+"="+v)
}
sw := &streamWriter{emit: emit}
c.Stdout = sw
c.Stderr = sw
runErr := c.Run()
// stdout/stderr are streamed via emit, not returned in the result.
if ctx.Err() == context.DeadlineExceeded {
res.ExitCode = 124
res.Stderr = "[vantage] step timed out"
} else if ee, ok := runErr.(*exec.ExitError); ok {
res.ExitCode = ee.ExitCode()
} else if runErr != nil {
res.ExitCode = 1
res.Stderr = "[vantage] " + runErr.Error()
}
res.OutputEnv = parseEnvFile(envFile)
return res
}
// parseEnvFile reads KEY=value lines (last write wins). Blank lines and lines
// without '=' are ignored.
func parseEnvFile(path string) map[string]string {
out := map[string]string{}
f, err := os.Open(path)
if err != nil {
return out
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := sc.Text()
i := strings.IndexByte(line, '=')
if i <= 0 {
continue
}
out[line[:i]] = line[i+1:]
}
return out
}
+50 -5
View File
@@ -6,11 +6,12 @@ import (
"strings"
"time"
"github.com/mrhid6/keymanager/agent/internal/grpc/pb"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/encoding"
"google.golang.org/grpc/keepalive"
)
func init() {
@@ -19,14 +20,22 @@ func init() {
type Client struct {
conn *grpc.ClientConn
client pb.KeyManagerClient
client pb.VantageClient
}
func New(serverURL string, useTLS bool) (*Client, error) {
serverURL = strings.TrimPrefix(serverURL, "https://")
serverURL = strings.TrimPrefix(serverURL, "http://")
var dialOpts []grpc.DialOption
// Send a ping every 30s so proxies with a 60s idle timeout don't kill the
// long-lived CommandStream when no commands are flowing.
dialOpts := []grpc.DialOption{
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
Timeout: 10 * time.Second,
PermitWithoutStream: false,
}),
}
if useTLS {
tlsCfg := &tls.Config{
@@ -48,7 +57,7 @@ func New(serverURL string, useTLS bool) (*Client, error) {
return &Client{
conn: conn,
client: pb.NewKeyManagerClient(conn),
client: pb.NewVantageClient(conn),
}, nil
}
@@ -105,8 +114,44 @@ func (c *Client) UploadGeneratedKey(serverID, agentToken, publicKey, privateKey,
return resp.KeyId, nil
}
func (c *Client) ReportUpdates(serverID, agentToken string, updates []pb.PackageUpdate) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := c.client.ReportUpdates(ctx, &pb.ReportUpdatesRequest{
ServerId: serverID,
AgentToken: agentToken,
Updates: updates,
})
return err
}
func (c *Client) ReportInventory(report *pb.InventoryReport) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := c.client.ReportInventory(ctx, report)
return err
}
func (c *Client) SyncMonitors(serverID, agentToken string) ([]pb.MonitorSpec, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := c.client.SyncMonitors(ctx, &pb.SyncMonitorsRequest{ServerId: serverID, AgentToken: agentToken})
if err != nil {
return nil, err
}
return resp.Monitors, nil
}
func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckResult) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := c.client.ReportChecks(ctx, &pb.ReportChecksRequest{ServerId: serverID, AgentToken: agentToken, Results: results})
return err
}
// CommandStream opens a long-lived bidirectional stream for server-pushed commands.
// The caller controls the stream lifetime via ctx.
func (c *Client) CommandStream(ctx context.Context) (pb.KeyManager_CommandStreamClient, error) {
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
return c.client.CommandStream(ctx)
}
-194
View File
@@ -1,194 +0,0 @@
// Hand-written gRPC bindings for keymanager.proto (agent side, JSON codec).
package pb
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type RegisterRequest struct {
ServerId string `json:"server_id"`
PreRegToken string `json:"pre_reg_token"`
Hostname string `json:"hostname"`
IpAddress string `json:"ip_address"`
OsInfo string `json:"os_info"`
}
type RegisterResponse struct {
AgentToken string `json:"agent_token"`
}
type SyncRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
AgentVersion string `json:"agent_version,omitempty"`
}
type SyncResponse struct {
PublicKeys []string `json:"public_keys"`
}
type UploadKeyRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
PublicKey string `json:"public_key"`
Label string `json:"label"`
PrivateKey string `json:"private_key,omitempty"`
}
type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
// CommandStream message types
type ServerCommand struct {
CommandId string `json:"command_id"`
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
}
type DeleteKeyCmd struct {
Label string `json:"label"`
}
type UpdateAgentCmd struct {
Version string `json:"version"`
GiteaBaseURL string `json:"gitea_base_url"`
}
type GenerateKeyCmd struct {
Label string `json:"label"`
KeyType string `json:"key_type,omitempty"`
KeySize int `json:"key_size,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
Comment string `json:"comment,omitempty"`
}
type AgentMessage struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Ready *AgentReady `json:"ready,omitempty"`
Result *CommandResult `json:"result,omitempty"`
}
type AgentReady struct{}
type CommandResult struct {
CommandId string `json:"command_id"`
Success bool `json:"success"`
Message string `json:"message"`
}
// CommandStream client-side interface
type KeyManager_CommandStreamClient interface {
Send(*AgentMessage) error
Recv() (*ServerCommand, error)
grpc.ClientStream
}
type keyManagerCommandStreamClient struct {
grpc.ClientStream
}
func (c *keyManagerCommandStreamClient) Send(m *AgentMessage) error {
return c.ClientStream.SendMsg(m)
}
func (c *keyManagerCommandStreamClient) Recv() (*ServerCommand, error) {
m := new(ServerCommand)
if err := c.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// CommandStream server-side interface (included for completeness)
type KeyManager_CommandStreamServer interface {
Send(*ServerCommand) error
Recv() (*AgentMessage, error)
grpc.ServerStream
}
type keyManagerCommandStreamServer struct {
grpc.ServerStream
}
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
return s.ServerStream.SendMsg(m)
}
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
m := new(AgentMessage)
if err := s.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type KeyManagerClient interface {
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
CommandStream(ctx context.Context, opts ...grpc.CallOption) (KeyManager_CommandStreamClient, error)
}
type UnimplementedKeyManagerServer struct{}
func (UnimplementedKeyManagerServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
func (UnimplementedKeyManagerServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
func (UnimplementedKeyManagerServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
type keyManagerClient struct {
cc grpc.ClientConnInterface
}
func NewKeyManagerClient(cc grpc.ClientConnInterface) KeyManagerClient {
return &keyManagerClient{cc}
}
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
out := new(RegisterResponse)
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/Register", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
out := new(SyncResponse)
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/SyncKeys", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
out := new(UploadKeyResponse)
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/UploadGeneratedKey", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (KeyManager_CommandStreamClient, error) {
desc := &grpc.StreamDesc{StreamName: "CommandStream", ServerStreams: true, ClientStreams: true}
stream, err := c.cc.NewStream(ctx, desc, "/keymanager.v1.KeyManager/CommandStream", opts...)
if err != nil {
return nil, err
}
return &keyManagerCommandStreamClient{stream}, nil
}
+351
View File
@@ -0,0 +1,351 @@
// Hand-written gRPC bindings for vantage.proto (agent side, JSON codec).
package pb
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type RegisterRequest struct {
ServerId string `json:"server_id"`
PreRegToken string `json:"pre_reg_token"`
Hostname string `json:"hostname"`
IpAddress string `json:"ip_address"`
OsInfo string `json:"os_info"`
}
type RegisterResponse struct {
AgentToken string `json:"agent_token"`
}
type SyncRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
AgentVersion string `json:"agent_version,omitempty"`
}
type SyncResponse struct {
PublicKeys []string `json:"public_keys"`
}
type UploadKeyRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
PublicKey string `json:"public_key"`
Label string `json:"label"`
PrivateKey string `json:"private_key,omitempty"`
}
type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
// CommandStream message types
type PackageUpdate struct {
Name string `json:"name"`
CurrentVersion string `json:"current_version,omitempty"`
NewVersion string `json:"new_version"`
}
type ReportUpdatesRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Updates []PackageUpdate `json:"updates"`
}
type ReportUpdatesResponse struct{}
// Inventory report message types
type CPUReport struct {
Model string `json:"model,omitempty"`
Cores int `json:"cores,omitempty"`
UsagePct float64 `json:"usage_pct"`
Load1 float64 `json:"load1,omitempty"`
}
type MemReport struct {
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
}
type PartitionReport struct {
Device string `json:"device"`
Mountpoint string `json:"mountpoint"`
Fstype string `json:"fstype,omitempty"`
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
}
type InventoryReport struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
IncludeStatic bool `json:"include_static"`
CPU *CPUReport `json:"cpu,omitempty"`
Memory *MemReport `json:"memory,omitempty"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
}
type InventoryReportResponse struct{}
// Monitor sync / check report message types
type MonitorSpec struct {
MonitorId string `json:"monitor_id"`
Type string `json:"type"`
URL string `json:"url,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Method string `json:"method,omitempty"`
ExpectedStatus int `json:"expected_status,omitempty"`
Keyword string `json:"keyword,omitempty"`
TLSWarnDays int `json:"tls_warn_days,omitempty"`
Insecure bool `json:"insecure,omitempty"`
IntervalSec int `json:"interval_sec"`
Retries int `json:"retries"`
}
type SyncMonitorsRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
}
type SyncMonitorsResponse struct {
Monitors []MonitorSpec `json:"monitors,omitempty"`
}
type CheckResult struct {
MonitorId string `json:"monitor_id"`
Up bool `json:"up"`
LatencyMs int `json:"latency_ms"`
Message string `json:"message,omitempty"`
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
}
type ReportChecksRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Results []CheckResult `json:"results,omitempty"`
}
type ReportChecksResponse struct{}
type ApplyUpdatesCmd struct{}
type ServerCommand struct {
CommandId string `json:"command_id"`
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
RunStep *RunStepCmd `json:"run_step,omitempty"`
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
}
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
// directory once all steps on that server have finished.
type CleanupWorkspaceCmd struct {
WorkspaceId string `json:"workspace_id"`
}
type DeleteKeyCmd struct {
Label string `json:"label"`
}
type UpdateAgentCmd struct {
Version string `json:"version"`
GiteaBaseURL string `json:"gitea_base_url"`
}
type GenerateKeyCmd struct {
Label string `json:"label"`
KeyType string `json:"key_type,omitempty"`
KeySize int `json:"key_size,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
Comment string `json:"comment,omitempty"`
}
type AgentMessage struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Ready *AgentReady `json:"ready,omitempty"`
Result *CommandResult `json:"result,omitempty"`
StepResult *StepResult `json:"step_result,omitempty"`
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
}
type AgentReady struct{}
type CommandResult struct {
CommandId string `json:"command_id"`
Success bool `json:"success"`
Message string `json:"message"`
}
type RunStepCmd struct {
Interpreter string `json:"interpreter"`
Script string `json:"script"`
Env map[string]string `json:"env,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
// WorkspaceId names the per-run working directory the agent creates and uses
// as the step's cwd. Empty means run in the agent's default directory.
WorkspaceId string `json:"workspace_id,omitempty"`
}
type StepResult struct {
CommandId string `json:"command_id"`
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
OutputEnv map[string]string `json:"output_env,omitempty"`
}
type StepOutputChunk struct {
CommandId string `json:"command_id"`
Seq uint64 `json:"seq"`
Data []byte `json:"data,omitempty"`
Eof bool `json:"eof,omitempty"`
}
// CommandStream client-side interface
type Vantage_CommandStreamClient interface {
Send(*AgentMessage) error
Recv() (*ServerCommand, error)
grpc.ClientStream
}
type vantageCommandStreamClient struct {
grpc.ClientStream
}
func (c *vantageCommandStreamClient) Send(m *AgentMessage) error {
return c.ClientStream.SendMsg(m)
}
func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
m := new(ServerCommand)
if err := c.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// CommandStream server-side interface (included for completeness)
type Vantage_CommandStreamServer interface {
Send(*ServerCommand) error
Recv() (*AgentMessage, error)
grpc.ServerStream
}
type keyManagerCommandStreamServer struct {
grpc.ServerStream
}
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
return s.ServerStream.SendMsg(m)
}
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
m := new(AgentMessage)
if err := s.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type VantageClient interface {
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
}
type UnimplementedVantageServer struct{}
func (UnimplementedVantageServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
func (UnimplementedVantageServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
func (UnimplementedVantageServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "not implemented")
}
type keyManagerClient struct {
cc grpc.ClientConnInterface
}
func NewVantageClient(cc grpc.ClientConnInterface) VantageClient {
return &keyManagerClient{cc}
}
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
out := new(RegisterResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/Register", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
out := new(SyncResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncKeys", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
out := new(UploadKeyResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/UploadGeneratedKey", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error) {
out := new(ReportUpdatesResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportUpdates", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
out := new(InventoryReportResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error) {
out := new(SyncMonitorsResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncMonitors", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error) {
out := new(ReportChecksResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportChecks", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
desc := &grpc.StreamDesc{StreamName: "CommandStream", ServerStreams: true, ClientStreams: true}
stream, err := c.cc.NewStream(ctx, desc, "/vantage.v1.Vantage/CommandStream", opts...)
if err != nil {
return nil, err
}
return &vantageCommandStreamClient{stream}, nil
}
+157
View File
@@ -0,0 +1,157 @@
//go:build linux
package inventory
import (
"bufio"
"os"
"strconv"
"strings"
"syscall"
"time"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
)
func collect(r *pb.InventoryReport, includeStatic bool) {
r.CPU.UsagePct = cpuUsage()
r.CPU.Load1 = load1()
memTotal, memAvail, swapTotal, swapFree := meminfo()
if memTotal > memAvail {
r.Memory.UsedBytes = memTotal - memAvail
}
if swapTotal > swapFree {
r.SwapUsed = swapTotal - swapFree
}
if includeStatic {
r.Memory.TotalBytes = memTotal
r.SwapTotal = swapTotal
r.CPU.Model, r.CPU.Cores = cpuStatic()
r.Kernel = kernel()
r.Partitions = partitions()
}
}
func readProc(path string) string { b, _ := os.ReadFile(path); return string(b) }
func cpuSample() (idle, total uint64) {
f, err := os.Open("/proc/stat")
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
if sc.Scan() {
fields := strings.Fields(sc.Text()) // cpu user nice system idle iowait ...
for i, v := range fields[1:] {
n, _ := strconv.ParseUint(v, 10, 64)
total += n
if i == 3 { // idle
idle = n
}
}
}
return
}
func cpuUsage() float64 {
i1, t1 := cpuSample()
time.Sleep(100 * time.Millisecond)
i2, t2 := cpuSample()
dt := float64(t2 - t1)
if dt <= 0 {
return 0
}
return (1 - float64(i2-i1)/dt) * 100
}
func load1() float64 {
fields := strings.Fields(readProc("/proc/loadavg"))
if len(fields) > 0 {
v, _ := strconv.ParseFloat(fields[0], 64)
return v
}
return 0
}
func meminfo() (total, avail, swapTotal, swapFree uint64) {
f, err := os.Open("/proc/meminfo")
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 2 {
continue
}
kb, _ := strconv.ParseUint(fields[1], 10, 64)
b := kb * 1024
switch strings.TrimSuffix(fields[0], ":") {
case "MemTotal":
total = b
case "MemAvailable":
avail = b
case "SwapTotal":
swapTotal = b
case "SwapFree":
swapFree = b
}
}
return
}
func cpuStatic() (model string, cores int) {
f, err := os.Open("/proc/cpuinfo")
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "processor") {
cores++
} else if strings.HasPrefix(line, "model name") && model == "" {
if i := strings.Index(line, ":"); i >= 0 {
model = strings.TrimSpace(line[i+1:])
}
}
}
return
}
func kernel() string {
return strings.TrimSpace(readProc("/proc/sys/kernel/osrelease"))
}
func partitions() []pb.PartitionReport {
allowed := map[string]bool{"ext4": true, "xfs": true, "btrfs": true, "zfs": true, "vfat": true, "ntfs": true, "ext3": true}
f, err := os.Open("/proc/mounts")
if err != nil {
return nil
}
defer f.Close()
var out []pb.PartitionReport
seen := map[string]bool{}
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 3 || !allowed[fields[2]] || seen[fields[1]] {
continue
}
seen[fields[1]] = true
var st syscall.Statfs_t
if syscall.Statfs(fields[1], &st) != nil {
continue
}
total := st.Blocks * uint64(st.Bsize)
free := st.Bavail * uint64(st.Bsize)
out = append(out, pb.PartitionReport{
Device: fields[0], Mountpoint: fields[1], Fstype: fields[2],
TotalBytes: total, UsedBytes: total - free,
})
}
return out
}
@@ -0,0 +1,8 @@
//go:build !linux
package inventory
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
// collect is a no-op best-effort stub on non-Linux platforms.
func collect(r *pb.InventoryReport, includeStatic bool) {}
+11
View File
@@ -0,0 +1,11 @@
package inventory
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
// Collect gathers metrics always and static hardware info when includeStatic.
// Platform specifics are provided by collect_linux.go / collect_other.go.
func Collect(includeStatic bool) *pb.InventoryReport {
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
collect(r, includeStatic)
return r
}
+4 -4
View File
@@ -12,8 +12,8 @@ import (
const authorizedKeysPath = "/root/.ssh/authorized_keys"
const sshConfigPath = "/root/.ssh/config"
const managedConfigPath = "/root/.ssh/keymanager.conf"
const includeDirective = "Include /root/.ssh/keymanager.conf"
const managedConfigPath = "/root/.ssh/vantage.conf"
const includeDirective = "Include /root/.ssh/vantage.conf"
func ReadAuthorizedKeys() ([]string, error) {
data, err := os.ReadFile(authorizedKeysPath)
@@ -140,7 +140,7 @@ func GenerateKeyPair(keyPath string, opts KeyGenOptions) (string, error) {
}
// AddSSHIdentity writes an IdentityFile entry for keyPath into the managed
// keymanager.conf include file, and ensures ~/.ssh/config includes it.
// vantage.conf include file, and ensures ~/.ssh/config includes it.
func AddSSHIdentity(keyPath string) error {
if err := os.MkdirAll(filepath.Dir(sshConfigPath), 0700); err != nil {
return fmt.Errorf("mkdir .ssh: %w", err)
@@ -204,7 +204,7 @@ func RemoveSSHIdentity(keyPath string) error {
return nil
}
// ensureIncludeDirective adds "Include /root/.ssh/keymanager.conf" to the top
// ensureIncludeDirective adds "Include /root/.ssh/vantage.conf" to the top
// of ~/.ssh/config if it is not already present. The Include must appear before
// any Host stanzas to be effective for all connections.
func ensureIncludeDirective() error {
+166
View File
@@ -0,0 +1,166 @@
// Package monitors runs agent-side service checks. It polls the server for the
// monitors assigned to this agent (SyncMonitors), runs each on its own interval
// using the local checker package, and reports results back (ReportChecks).
package monitors
import (
"context"
"log"
"sync"
"time"
"github.com/mrhid6/vantage/agent/internal/checker"
"github.com/mrhid6/vantage/agent/internal/config"
grpcclient "github.com/mrhid6/vantage/agent/internal/grpc"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
)
// syncInterval controls how often the agent re-fetches its assigned monitors.
const syncInterval = 30 * time.Second
type runner struct {
intervalSec int
cancel context.CancelFunc
}
// Run starts the agent monitor loop and blocks until ctx is cancelled.
func Run(ctx context.Context, cfg *config.Config) {
active := map[string]*runner{}
var mu sync.Mutex
// results is a shared channel every check writes to; a single reporter
// goroutine batches and ships them so we make one ReportChecks call per tick.
results := make(chan pb.CheckResult, 64)
go reporter(ctx, cfg, results)
syncOnce := func() {
specs, err := fetchSpecs(cfg)
if err != nil {
log.Printf("monitors: sync: %v", err)
return
}
want := map[string]pb.MonitorSpec{}
for _, s := range specs {
want[s.MonitorId] = s
}
mu.Lock()
defer mu.Unlock()
for id, r := range active {
s, ok := want[id]
if !ok || s.IntervalSec != r.intervalSec {
r.cancel()
delete(active, id)
}
}
for id, s := range want {
if _, ok := active[id]; ok {
continue
}
rctx, cancel := context.WithCancel(ctx)
active[id] = &runner{intervalSec: s.IntervalSec, cancel: cancel}
go runSpec(rctx, s, results)
}
}
syncOnce()
t := time.NewTicker(syncInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
syncOnce()
}
}
}
func fetchSpecs(cfg *config.Config) ([]pb.MonitorSpec, error) {
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
return nil, err
}
defer client.Close()
return client.SyncMonitors(cfg.ServerID, cfg.AgentToken)
}
func runSpec(ctx context.Context, s pb.MonitorSpec, out chan<- pb.CheckResult) {
interval := time.Duration(s.IntervalSec) * time.Second
if interval <= 0 {
interval = 60 * time.Second
}
spec := checker.Spec{
Type: s.Type,
URL: s.URL,
Host: s.Host,
Port: s.Port,
Method: s.Method,
ExpectedStatus: s.ExpectedStatus,
Keyword: s.Keyword,
TLSWarnDays: s.TLSWarnDays,
Insecure: s.Insecure,
TimeoutSec: s.IntervalSec,
}
run := func() {
res := checker.Run(ctx, spec)
cr := pb.CheckResult{MonitorId: s.MonitorId, Up: res.Up, LatencyMs: res.LatencyMs, Message: res.Message}
if res.CertExpiry != nil {
cr.CertExpiryUnix = res.CertExpiry.Unix()
}
select {
case out <- cr:
case <-ctx.Done():
}
}
run()
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
run()
}
}
}
// reporter batches results on a short interval and ships each batch in one call.
func reporter(ctx context.Context, cfg *config.Config, in <-chan pb.CheckResult) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
var batch []pb.CheckResult
flush := func() {
if len(batch) == 0 {
return
}
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
log.Printf("monitors: report dial: %v", err)
batch = nil
return
}
if err := client.ReportChecks(cfg.ServerID, cfg.AgentToken, batch); err != nil {
log.Printf("monitors: report: %v", err)
}
client.Close()
batch = nil
}
for {
select {
case <-ctx.Done():
flush()
return
case r := <-in:
batch = append(batch, r)
if len(batch) >= 32 {
flush()
}
case <-t.C:
flush()
}
}
}
+224 -13
View File
@@ -11,14 +11,20 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/mrhid6/keymanager/agent/internal/config"
grpcclient "github.com/mrhid6/keymanager/agent/internal/grpc"
"github.com/mrhid6/keymanager/agent/internal/grpc/pb"
"github.com/mrhid6/keymanager/agent/internal/keys"
"github.com/mrhid6/vantage/agent/internal/config"
agentexec "github.com/mrhid6/vantage/agent/internal/exec"
grpcclient "github.com/mrhid6/vantage/agent/internal/grpc"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
"github.com/mrhid6/vantage/agent/internal/inventory"
"github.com/mrhid6/vantage/agent/internal/keys"
"github.com/mrhid6/vantage/agent/internal/monitors"
"github.com/mrhid6/vantage/agent/internal/updates"
)
func Run(ctx context.Context, cfg *config.Config, version string) error {
@@ -61,6 +67,15 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
// Start the command stream alongside the poll loop.
go runCommandStream(ctx, cfg)
// Check for OS updates on startup and then hourly.
go runUpdateCheck(ctx, cfg)
// Report host inventory: metrics every 30s, full static snapshot every 15 min.
go runInventory(ctx, cfg)
// Run agent-side service monitors assigned to this server.
go monitors.Run(ctx, cfg)
ticker := time.NewTicker(cfg.PollInterval)
defer ticker.Stop()
@@ -87,6 +102,11 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
return fmt.Errorf("SyncKeys: %w", err)
}
// Windows agents register and heartbeat only — no authorized_keys management.
if runtime.GOOS != "linux" {
return nil
}
current, err := keys.ReadAuthorizedKeys()
if err != nil {
return fmt.Errorf("read authorized_keys: %w", err)
@@ -158,6 +178,16 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
log.Println("command stream connected")
// grpc streams are not safe for concurrent Send; RunStep results are sent
// from per-command goroutines, so all sends on this stream must go through
// this mutex-protected helper.
var sendMu sync.Mutex
send := func(msg *pb.AgentMessage) error {
sendMu.Lock()
defer sendMu.Unlock()
return stream.Send(msg)
}
for {
cmd, err := stream.Recv()
if err != nil {
@@ -173,12 +203,147 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
if cmd.UpdateAgent != nil {
go handleUpdateAgent(cmd)
}
if cmd.ApplyUpdates != nil {
go handleApplyUpdates(cfg, cmd)
}
if cmd.CleanupWorkspace != nil {
go handleCleanupWorkspace(cmd)
}
if cmd.RunStep != nil {
go func(rc *pb.RunStepCmd, cid string) {
emit := func(seq uint64, data []byte) {
_ = send(&pb.AgentMessage{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
StepOutput: &pb.StepOutputChunk{CommandId: cid, Seq: seq, Data: data},
})
}
res := agentexec.RunStep(rc, emit)
res.CommandId = cid
// Final eof marker so the server closes the log file.
_ = send(&pb.AgentMessage{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
StepOutput: &pb.StepOutputChunk{CommandId: cid, Eof: true},
})
_ = send(&pb.AgentMessage{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
StepResult: res,
})
}(cmd.RunStep, cmd.CommandId)
continue
}
}
}
func runUpdateCheck(ctx context.Context, cfg *config.Config) {
const interval = time.Hour
doCheck := func() {
pkgs, err := updates.CheckAvailable()
if err != nil {
log.Printf("update check error: %v", err)
return
}
pbUpdates := make([]pb.PackageUpdate, len(pkgs))
for i, p := range pkgs {
pbUpdates[i] = pb.PackageUpdate{
Name: p.Name,
CurrentVersion: p.CurrentVersion,
NewVersion: p.NewVersion,
}
}
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
log.Printf("update report dial error: %v", err)
return
}
defer client.Close()
if err := client.ReportUpdates(cfg.ServerID, cfg.AgentToken, pbUpdates); err != nil {
log.Printf("ReportUpdates error: %v", err)
return
}
log.Printf("reported %d available OS updates", len(pkgs))
}
doCheck()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
doCheck()
}
}
}
// runInventory reports host metrics every 30s and a full static snapshot every
// 15 min (and once immediately on startup so static fields populate without delay).
func runInventory(ctx context.Context, cfg *config.Config) {
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
log.Printf("inventory dial error: %v", err)
return
}
defer client.Close()
report := func(static bool) {
r := inventory.Collect(static)
r.ServerId = cfg.ServerID
r.AgentToken = cfg.AgentToken
if err := client.ReportInventory(r); err != nil {
log.Printf("report inventory: %v", err)
}
}
report(true) // full snapshot on startup
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
tick := 0
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
tick++
report(tick%30 == 0) // every 30th tick = 15 min → include static
}
}
}
func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) {
log.Printf("applying OS updates (cmd=%s)…", cmd.CommandId)
if err := updates.ApplyAll(); err != nil {
log.Printf("OS upgrade failed (cmd=%s): %v", cmd.CommandId, err)
return
}
log.Printf("OS updates applied successfully (cmd=%s)", cmd.CommandId)
// Re-report the (now empty) update list so the server reflects the new state.
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
return
}
defer client.Close()
_ = client.ReportUpdates(cfg.ServerID, cfg.AgentToken, nil)
}
func handleCleanupWorkspace(cmd *pb.ServerCommand) {
id := cmd.CleanupWorkspace.WorkspaceId
dir := agentexec.WorkspacePath(id)
if err := os.RemoveAll(dir); err != nil {
log.Printf("cleanup workspace %s failed (cmd=%s): %v", dir, cmd.CommandId, err)
return
}
log.Printf("removed run workspace %s (cmd=%s)", dir, cmd.CommandId)
}
func handleDeleteKey(cmd *pb.ServerCommand) {
label := cmd.DeleteKey.Label
keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_"))
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
if err := keys.RemoveSSHIdentity(keyPath); err != nil {
log.Printf("remove ssh identity failed (cmd=%s): %v", cmd.CommandId, err)
@@ -193,16 +358,21 @@ func handleDeleteKey(cmd *pb.ServerCommand) {
}
func handleUpdateAgent(cmd *pb.ServerCommand) {
if runtime.GOOS == "windows" {
handleUpdateAgentWindows(cmd)
return
}
u := cmd.UpdateAgent
arch := runtime.GOARCH // "amd64" or "arm64"
tag := "agent%2Fv" + u.Version
binaryURL := fmt.Sprintf("%s/mrhid6/keymanager/releases/download/%s/keymanager-agent-linux-%s", u.GiteaBaseURL, tag, arch)
checksumURL := fmt.Sprintf("%s/mrhid6/keymanager/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
binaryURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch)
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
// Download binary
tmpBin := "/tmp/keymanager-agent-update"
tmpBin := "/tmp/vantage-agent-update"
if err := downloadFile(binaryURL, tmpBin); err != nil {
log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err)
return
@@ -214,7 +384,7 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err)
return
}
if err := verifyChecksum(tmpBin, fmt.Sprintf("keymanager-agent-linux-%s", arch), checksumData); err != nil {
if err := verifyChecksum(tmpBin, fmt.Sprintf("vantage-agent-linux-%s", arch), checksumData); err != nil {
log.Printf("update checksum mismatch (cmd=%s): %v", cmd.CommandId, err)
os.Remove(tmpBin)
return
@@ -224,13 +394,54 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
log.Printf("update chmod failed (cmd=%s): %v", cmd.CommandId, err)
return
}
if err := os.Rename(tmpBin, "/usr/local/bin/keymanager-agent"); err != nil {
if err := os.Rename(tmpBin, "/usr/local/bin/vantage-agent"); err != nil {
log.Printf("update replace binary failed (cmd=%s): %v", cmd.CommandId, err)
return
}
log.Printf("agent binary replaced, restarting service (cmd=%s)", cmd.CommandId)
exec.Command("systemctl", "restart", "keymanager-agent").Run()
exec.Command("systemctl", "restart", "vantage-agent").Run()
}
// handleUpdateAgentWindows downloads the latest MSI and launches msiexec to
// perform a MajorUpgrade. msiexec is started DETACHED (via "cmd /c start") so
// that when the upgrade stops the VantageAgent service, nssm's process-tree
// kill of this agent does not also kill the installer mid-flight. Config
// (server_id, agent_token) is preserved by setup.ps1 on upgrade.
func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
u := cmd.UpdateAgent
tag := "agent%2Fv" + u.Version
msiURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent.msi", u.GiteaBaseURL, tag)
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums-msi.txt", u.GiteaBaseURL, tag)
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
msiPath := filepath.Join(os.TempDir(), "vantage-agent-update.msi")
if err := downloadFile(msiURL, msiPath); err != nil {
log.Printf("update download failed (cmd=%s): %v", cmd.CommandId, err)
return
}
checksumData, err := httpGetBytes(checksumURL)
if err != nil {
log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err)
return
}
if err := verifyChecksum(msiPath, "vantage-agent.msi", checksumData); err != nil {
log.Printf("update checksum mismatch (cmd=%s): %v", cmd.CommandId, err)
os.Remove(msiPath)
return
}
logPath := filepath.Join(os.TempDir(), "vantage-agent-msi.log")
log.Printf("launching msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId)
// "start" detaches msiexec from this process tree so the service stop
// during the upgrade does not terminate the installer.
up := exec.Command("cmd", "/c", "start", "", "/wait", "msiexec", "/i", msiPath, "/qn", "/norestart", "/l*v", logPath)
if err := up.Start(); err != nil {
log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err)
return
}
}
func downloadFile(url, dest string) error {
@@ -290,7 +501,7 @@ func verifyChecksum(filePath, filename string, checksumData []byte) error {
func handleGenerateKey(cfg *config.Config, cmd *pb.ServerCommand) {
g := cmd.GenerateKey
label := g.Label
keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_"))
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
opts := keys.KeyGenOptions{
KeyType: g.KeyType,
@@ -352,7 +563,7 @@ func GenerateAndUpload(cfg *config.Config, label string) error {
}
defer client.Close()
keyPath := fmt.Sprintf("/root/.ssh/keymanager_%s", strings.ReplaceAll(label, " ", "_"))
keyPath := fmt.Sprintf("/root/.ssh/vantage_%s", strings.ReplaceAll(label, " ", "_"))
pubKey, err := keys.GenerateKeyPair(keyPath, keys.KeyGenOptions{Comment: label})
if err != nil {
return err
+239
View File
@@ -0,0 +1,239 @@
package updates
import (
"bufio"
"bytes"
"context"
"os/exec"
"strings"
"time"
)
type PackageUpdate struct {
Name string
CurrentVersion string
NewVersion string
}
func detectPM() string {
for _, pm := range []string{"apt-get", "dnf", "yum", "pacman", "zypper", "apk"} {
if _, err := exec.LookPath(pm); err == nil {
if pm == "apt-get" {
return "apt"
}
return pm
}
}
return ""
}
// CheckAvailable returns the list of packages with available upgrades.
// Returns nil, nil when no supported package manager is found.
func CheckAvailable() ([]PackageUpdate, error) {
switch detectPM() {
case "apt":
return checkApt()
case "dnf":
return checkDnfYum("dnf")
case "yum":
return checkDnfYum("yum")
case "pacman":
return checkPacman()
case "zypper":
return checkZypper()
case "apk":
return checkApk()
default:
return nil, nil
}
}
// ApplyAll runs a full non-interactive upgrade using the detected package manager.
func ApplyAll() error {
switch detectPM() {
case "apt":
// Refresh lists first, then upgrade.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
return err
}
return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run()
case "dnf":
return exec.Command("dnf", "upgrade", "-y").Run()
case "yum":
return exec.Command("yum", "upgrade", "-y").Run()
case "pacman":
return exec.Command("pacman", "-Syu", "--noconfirm").Run()
case "zypper":
return exec.Command("zypper", "update", "-y").Run()
case "apk":
return exec.Command("apk", "upgrade").Run()
default:
return nil
}
}
func checkApt() ([]PackageUpdate, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
// Best-effort refresh; ignore errors (cached data is fine).
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run() //nolint:errcheck
out, err := exec.Command("apt", "list", "--upgradable").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
// Format: package/suite version arch [upgradable from: old-ver]
if !strings.Contains(line, "[upgradable from:") {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
name := strings.SplitN(parts[0], "/", 2)[0]
newVer := parts[1]
oldVer := ""
if idx := strings.Index(line, "upgradable from: "); idx != -1 {
rest := line[idx+len("upgradable from: "):]
oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]")
}
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
}
return updates, nil
}
func checkDnfYum(pm string) ([]PackageUpdate, error) {
cmd := exec.Command(pm, "check-update")
out, err := cmd.Output()
// Exit code 100 means updates are available — not an error.
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
err = nil
}
if err != nil {
return nil, err
}
var updates []PackageUpdate
pastHeader := false
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !pastHeader {
if strings.TrimSpace(line) == "" {
pastHeader = true
}
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
// name.arch new-version repo
name := strings.SplitN(parts[0], ".", 2)[0]
updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]})
}
return updates, nil
}
func checkPacman() ([]PackageUpdate, error) {
out, _ := exec.Command("pacman", "-Qu").Output()
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
parts := strings.Fields(scanner.Text())
// Format: package old-version -> new-version
if len(parts) < 4 {
continue
}
updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]})
}
return updates, nil
}
func checkZypper() ([]PackageUpdate, error) {
out, err := exec.Command("zypper", "list-updates").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
// Data rows start with "v |" (available) or "i |" (installed but updatable).
if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") {
continue
}
parts := strings.Split(line, "|")
if len(parts) < 5 {
continue
}
updates = append(updates, PackageUpdate{
Name: strings.TrimSpace(parts[2]),
CurrentVersion: strings.TrimSpace(parts[3]),
NewVersion: strings.TrimSpace(parts[4]),
})
}
return updates, nil
}
func checkApk() ([]PackageUpdate, error) {
out, err := exec.Command("apk", "list", "--upgradable").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.Contains(line, "[upgradable") {
continue
}
parts := strings.Fields(line)
if len(parts) < 1 {
continue
}
pkgVer := parts[0]
name := apkName(pkgVer)
newVer := apkVersion(pkgVer)
oldVer := ""
if idx := strings.Index(line, "upgradable from:"); idx != -1 {
rest := strings.TrimSpace(line[idx+len("upgradable from:"):])
rest = strings.TrimSuffix(rest, "]")
oldVer = apkVersion(strings.TrimSpace(rest))
}
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
}
return updates, nil
}
func apkName(pkgVer string) string {
parts := strings.Split(pkgVer, "-")
var name []string
for _, p := range parts {
if len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
break
}
name = append(name, p)
}
return strings.Join(name, "-")
}
func apkVersion(pkgVer string) string {
parts := strings.Split(pkgVer, "-")
var ver []string
inVer := false
for _, p := range parts {
if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
inVer = true
}
if inVer {
ver = append(ver, p)
}
}
return strings.Join(ver, "-")
}
+17 -17
View File
@@ -1,4 +1,4 @@
# KeyManager
# Vantage
A self-hosted SSH key management system. A central server (Go + Next.js + MongoDB) manages public key assignments across servers. A lightweight Go agent runs on each managed server, polls the central server via gRPC, and atomically rewrites `/root/.ssh/authorized_keys` to match the desired state.
@@ -34,7 +34,7 @@ A self-hosted SSH key management system. A central server (Go + Next.js + MongoD
## Repository Structure
```
keymanager/
vantage/
├── agent/
│ ├── cmd/main.go
│ └── internal/
@@ -56,7 +56,7 @@ keymanager/
│ ├── app/
│ └── components/
├── proto/
│ └── keymanager/v1/keymanager.proto
│ └── vantage/v1/vantage.proto
├── deploy/
│ ├── docker-compose.yml
│ └── agent.service
@@ -72,9 +72,9 @@ keymanager/
```protobuf
syntax = "proto3";
package keymanager.v1;
package vantage.v1;
service KeyManager {
service Vantage {
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
@@ -172,10 +172,10 @@ No streaming — polling only. Poll interval: **30 seconds**.
## Agent Lifecycle
### Config file — `/etc/keymanager/config.yaml`
### Config file — `/etc/vantage/config.yaml`
```yaml
server_url: "keymanager.yourdomain.com:9090"
server_url: "vantage.yourdomain.com:9090"
server_id: "<uuid>"
pre_reg_token: "<token>" # removed after first successful Register()
agent_token: "" # written by agent after Register()
@@ -216,15 +216,15 @@ Config file permissions: `0600`. Config directory: `0700`.
- Uploads public key via `UploadGeneratedKey()`
- Private key stays local on the machine
### Systemd unit — `/etc/systemd/system/keymanager-agent.service`
### Systemd unit — `/etc/systemd/system/vantage-agent.service`
```ini
[Unit]
Description=KeyManager Agent
Description=Vantage Agent
After=network.target
[Service]
ExecStart=/usr/local/bin/keymanager-agent
ExecStart=/usr/local/bin/vantage-agent
Restart=always
RestartSec=10
User=root
@@ -241,14 +241,14 @@ WantedBy=multi-user.target
2. Backend generates a short-lived pre-registration token (TTL: 1 hour) and a `server_id`
3. UI displays a one-liner install command with copy button:
```bash
curl -fsSL https://keymanager.yourdomain.com/install | \
curl -fsSL https://vantage.yourdomain.com/install | \
bash -s -- --server-id=<id> --token=<token>
```
4. Install script:
- Detects arch (`amd64` / `arm64`)
- Downloads agent binary from Gitea release
- Verifies SHA-256 checksum
- Writes `/etc/keymanager/config.yaml`
- Writes `/etc/vantage/config.yaml`
- Installs and starts systemd unit
5. On first `SyncKeys` call, server marks status as `active`
@@ -259,7 +259,7 @@ The backend serves `/install` dynamically, injecting the latest agent version by
## Security
- gRPC over TLS (Let's Encrypt or self-signed with cert pinning on the agent)
- Agent authenticates with a per-server token stored at `/etc/keymanager/config.yaml` (`0600`)
- Agent authenticates with a per-server token stored at `/etc/vantage/config.yaml` (`0600`)
- Server stores `SHA-256(agent_token)` — never the plaintext token
- Private keys generated by agents are encrypted at rest in MongoDB (AES-256)
- `authorized_keys` written as `0600`, owned by root
@@ -298,13 +298,13 @@ Build command:
```bash
GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/keymanager-agent-linux-amd64 ./cmd
-o dist/vantage-agent-linux-amd64 ./cmd
```
Release assets:
- `keymanager-agent-linux-amd64`
- `keymanager-agent-linux-arm64`
- `vantage-agent-linux-amd64`
- `vantage-agent-linux-arm64`
- `checksums.txt`
### Server deploy — `.gitea/workflows/server-deploy.yml`
@@ -312,7 +312,7 @@ Release assets:
Triggered on pushes to `main` touching `server/**`, `web/**`, or `proto/**`. Builds and pushes Docker images to the Gitea container registry, then deploys via SSH:
```bash
cd /opt/keymanager && docker compose pull && docker compose up -d --remove-orphans
cd /opt/vantage && docker compose pull && docker compose up -d --remove-orphans
```
### Tagging convention
+4 -4
View File
@@ -1,18 +1,18 @@
[Unit]
Description=KeyManager Agent
Documentation=https://github.com/your-org/keymanager
Description=Vantage Agent
Documentation=https://github.com/your-org/vantage
After=network.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/keymanager-agent
ExecStart=/usr/local/bin/vantage-agent
Restart=always
RestartSec=10
User=root
StandardOutput=journal
StandardError=journal
SyslogIdentifier=keymanager-agent
SyslogIdentifier=vantage-agent
# Security hardening
NoNewPrivileges=true
+24 -33
View File
@@ -1,37 +1,30 @@
services:
mongo:
image: mongo:8
restart: unless-stopped
volumes:
- mongo_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
redis:
image: redis:8
restart: unless-stopped
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
test:
- CMD
- redis-cli
- ping
interval: 10s
timeout: 5s
retries: 5
server:
build:
context: ../server
dockerfile: Dockerfile
guacd:
image: docker.io/guacamole/guacd:1.6.0
restart: unless-stopped
ports:
- "8080:8080"
- "9090:9090"
- 4822:4822
server:
image: gitea.hostxtra.co.uk/mrhid6/vantage/server:latest
restart: unless-stopped
ports:
- 8080:8080
- 9090:9090
environment:
MONGO_URI: mongodb://mongo:27017/keymanager
MONGO_URI: ${MONGO_URI:-}
REDIS_ADDR: redis:6379
GITEA_HOST: ${GITEA_HOST}
PUBLIC_HOST: ${PUBLIC_HOST}
@@ -42,24 +35,22 @@ services:
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
KEY_ENCRYPTION_KEY: ${KEY_ENCRYPTION_KEY:-}
VANTAGE_WORKFLOW_LOG_DIR: ${VANTAGE_WORKFLOW_LOG_DIR:-}
GUACD_ADDR: guacd:4822
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./data:/data
web:
build:
context: ../web
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: http://server:8080
image: gitea.hostxtra.co.uk/mrhid6/vantage/web:latest
restart: unless-stopped
ports:
- "3000:3000"
- 3000:3000
depends_on:
- server
volumes:
mongo_data:
redis_data:
mongo_data: null
redis_data: null
networks: {}
@@ -0,0 +1,866 @@
# Fleet Inventory Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Agents collect CPU/RAM/swap/disk/partition inventory and report it to the server via a new `ReportInventory` RPC; the server stores the latest snapshot per server and the UI displays it.
**Architecture:** New unary gRPC `ReportInventory` (mirrors existing `ReportUpdates`). Agent runs a 30s metrics ticker (CPU/RAM/swap usage) and, every 15 min, a full static collection (disks, partitions, CPU model, kernel). Server upserts an embedded `inventory` sub-doc on the `servers` document with merge rules that preserve static fields between slow ticks.
**Tech Stack:** Go (gin, mongo-driver v2, hand-written JSON-codec gRPC), `/proc` readers, Next.js 16 + react-query + Tailwind.
## Global Constraints
- **No tests this iteration.** Verify with `go build ./...`, `go vet ./...`, `npm run build`.
- gRPC uses a JSON codec: edit **both** `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go` identically, plus `proto/vantage/v1/vantage.proto` as documentation. No codegen. Mirror the existing `ReportUpdates` RPC wiring exactly (service interface, `_Vantage_*_Handler`, client method, `Vantage_ServiceDesc`).
- Mongo: `db.Col("servers")`, `context.WithTimeout`. Follow `server/internal/services/servers.go`.
- Agent already runs as root; `/proc` is readable. Linux is primary; Windows collectors may return empty.
- Module path `github.com/mrhid6/vantage`.
- Do not add heavy dependencies; implement `/proc` parsing directly.
---
## Task 1: Inventory model + gRPC messages
**Files:**
- Modify: `server/internal/models/server.go`
- Modify: `proto/vantage/v1/vantage.proto`
- Modify: `server/internal/grpc/pb/vantage.pb.go`
- Modify: `agent/internal/grpc/pb/vantage.pb.go`
**Interfaces:**
- Produces: `models.Inventory` (+ `CPUInfo`, `MemInfo`, `Partition`) and `Server.Inventory *Inventory`. pb structs `InventoryReport`, `CPUReport`, `MemReport`, `PartitionReport`, `InventoryReportResponse`. Service method `ReportInventory` on both client and server interfaces.
- [ ] **Step 1: Add model structs**
In `server/internal/models/server.go` add (keep the existing `import "time"`):
```go
type CPUInfo struct {
Model string `bson:"model,omitempty" json:"model,omitempty"`
Cores int `bson:"cores,omitempty" json:"cores,omitempty"`
UsagePct float64 `bson:"usage_pct" json:"usage_pct"`
Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"`
}
type MemInfo struct {
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
}
type Partition struct {
Device string `bson:"device" json:"device"`
Mountpoint string `bson:"mountpoint" json:"mountpoint"`
Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"`
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
}
type Inventory struct {
CPU CPUInfo `bson:"cpu" json:"cpu"`
Memory MemInfo `bson:"memory" json:"memory"`
SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"`
SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"`
Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"`
Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"`
MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
}
```
Add to the `Server` struct: `Inventory *Inventory \`bson:"inventory,omitempty" json:"inventory,omitempty"\``.
- [ ] **Step 2: Document RPC in proto**
In `proto/vantage/v1/vantage.proto`, add to the service: `rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);` and the messages `InventoryReport`, `CPUReport`, `MemReport`, `PartitionReport`, `InventoryReportResponse` per spec §4.
- [ ] **Step 3: Add pb structs + RPC wiring (server pb)**
In `server/internal/grpc/pb/vantage.pb.go` add the message structs:
```go
type CPUReport struct {
Model string `json:"model,omitempty"`
Cores int `json:"cores,omitempty"`
UsagePct float64 `json:"usage_pct"`
Load1 float64 `json:"load1,omitempty"`
}
type MemReport struct {
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
}
type PartitionReport struct {
Device string `json:"device"`
Mountpoint string `json:"mountpoint"`
Fstype string `json:"fstype,omitempty"`
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
}
type InventoryReport struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
IncludeStatic bool `json:"include_static"`
CPU *CPUReport `json:"cpu,omitempty"`
Memory *MemReport `json:"memory,omitempty"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
}
type InventoryReportResponse struct{}
```
Then mirror the `ReportUpdates` RPC plumbing for `ReportInventory`. Locate every `ReportUpdates` reference in this file and add the parallel `ReportInventory`:
- `VantageServer` interface: add `ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error)`.
- `UnimplementedVantageServer`: add the stub returning `Unimplemented`.
- `VantageClient` interface + `keyManagerClient`: add the client method `Invoke`-ing `/vantage.v1.Vantage/ReportInventory`.
- `Vantage_ServiceDesc.Methods`: add `{MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler}`.
- Add `_Vantage_ReportInventory_Handler` copied from `_Vantage_ReportUpdates_Handler` with types swapped.
- [ ] **Step 4: Mirror pb structs + wiring (agent pb)**
Apply the identical additions to `agent/internal/grpc/pb/vantage.pb.go`.
- [ ] **Step 5: Verify build**
Run: `cd server && go build ./... && cd ../agent && go build ./...`
Expected: both succeed.
- [ ] **Step 6: Commit**
```bash
git add server/internal/models/server.go proto/vantage/v1/vantage.proto server/internal/grpc/pb/vantage.pb.go agent/internal/grpc/pb/vantage.pb.go
git commit -m "feat(proto): add ReportInventory RPC and inventory model"
```
---
## Task 2: Server handler + store service
**Files:**
- Create: `server/internal/services/inventory.go`
- Modify: `server/internal/grpc/server.go`
**Interfaces:**
- Consumes: `pb.InventoryReport` (T1), `db.Col("servers")`.
- Produces: `services.StoreInventory(serverID string, r *pb.InventoryReport) error`; gRPC method `(*vantageServer).ReportInventory`.
- [ ] **Step 1: Write the store service**
```go
package services
import (
"context"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/grpc/pb"
"go.mongodb.org/mongo-driver/v2/bson"
)
// StoreInventory upserts the latest inventory snapshot onto the server document.
// Metrics fields update every call; static fields only when r.IncludeStatic.
func StoreInventory(serverID string, r *pb.InventoryReport) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
set := bson.M{"inventory.metrics_at": now}
if r.CPU != nil {
set["inventory.cpu.usage_pct"] = r.CPU.UsagePct
set["inventory.cpu.load1"] = r.CPU.Load1
}
if r.Memory != nil {
set["inventory.memory.used_bytes"] = r.Memory.UsedBytes
}
set["inventory.swap_used_bytes"] = r.SwapUsed
if r.IncludeStatic {
set["inventory.static_at"] = now
set["inventory.swap_total_bytes"] = r.SwapTotal
set["inventory.kernel"] = r.Kernel
if r.CPU != nil {
set["inventory.cpu.model"] = r.CPU.Model
set["inventory.cpu.cores"] = r.CPU.Cores
}
if r.Memory != nil {
set["inventory.memory.total_bytes"] = r.Memory.TotalBytes
}
parts := make([]bson.M, 0, len(r.Partitions))
for _, p := range r.Partitions {
parts = append(parts, bson.M{
"device": p.Device, "mountpoint": p.Mountpoint, "fstype": p.Fstype,
"total_bytes": p.TotalBytes, "used_bytes": p.UsedBytes,
})
}
set["inventory.partitions"] = parts
}
_, err := db.Col("servers").UpdateOne(ctx, bson.M{"server_id": serverID}, bson.M{"$set": set})
return err
}
```
- [ ] **Step 2: Add the gRPC handler**
In `server/internal/grpc/server.go`, add (mirroring the existing `ReportUpdates` handler that validates the agent token):
```go
func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryReport) (*pb.InventoryReportResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
if err := services.StoreInventory(srv.ServerID, req); err != nil {
log.Printf("store inventory for %s: %v", srv.ServerID, err)
}
return &pb.InventoryReportResponse{}, nil
}
```
Confirm `status`, `codes`, `log` are already imported in the file (they are, used by other handlers).
- [ ] **Step 3: Verify build**
Run: `cd server && go build ./... && go vet ./...`
Expected: success.
- [ ] **Step 4: Commit**
```bash
git add server/internal/services/inventory.go server/internal/grpc/server.go
git commit -m "feat(server): store inventory and handle ReportInventory RPC"
```
---
## Task 3: Agent collectors
**Files:**
- Create: `agent/internal/inventory/collect_linux.go`
- Create: `agent/internal/inventory/collect_other.go`
- Create: `agent/internal/inventory/inventory.go`
**Interfaces:**
- Produces: `inventory.Collect(includeStatic bool) *pb.InventoryReport`.
- [ ] **Step 1: Common entry (`inventory.go`)**
```go
package inventory
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
// Collect gathers metrics always and static hardware info when includeStatic.
// Platform specifics are provided by collect_linux.go / collect_other.go.
func Collect(includeStatic bool) *pb.InventoryReport {
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
collect(r, includeStatic)
return r
}
```
- [ ] **Step 2: Linux collector (`collect_linux.go`)**
Build-tagged `//go:build linux`. Implement `collect(r *pb.InventoryReport, includeStatic bool)`:
- CPU usage: read `/proc/stat` first line twice ~100ms apart, compute `1 - idleDelta/totalDelta` × 100 → `r.CPU.UsagePct`.
- Load: first field of `/proc/loadavg` → `r.CPU.Load1`.
- Mem/swap: parse `/proc/meminfo` (`MemTotal`, `MemAvailable`, `SwapTotal`, `SwapFree`; used = total − available; swap used = swaptotal − swapfree) → `r.Memory.*`, `r.SwapUsed`, and on static `r.SwapTotal`.
- Static only: `/proc/cpuinfo` (`model name`, count `processor` lines) → `r.CPU.Model/Cores`; `/proc/meminfo MemTotal` → `r.Memory.TotalBytes`; kernel via `syscall.Uname` or read `/proc/sys/kernel/osrelease` → `r.Kernel`; partitions from `/proc/mounts` filtered to fstypes in {ext4,xfs,btrfs,zfs,vfat,ntfs} then `syscall.Statfs` for total/used → `r.Partitions`.
```go
//go:build linux
package inventory
import (
"bufio"
"os"
"strconv"
"strings"
"syscall"
"time"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
)
func collect(r *pb.InventoryReport, includeStatic bool) {
r.CPU.UsagePct = cpuUsage()
r.CPU.Load1 = load1()
memTotal, memAvail, swapTotal, swapFree := meminfo()
if memTotal > memAvail {
r.Memory.UsedBytes = memTotal - memAvail
}
if swapTotal > swapFree {
r.SwapUsed = swapTotal - swapFree
}
if includeStatic {
r.Memory.TotalBytes = memTotal
r.SwapTotal = swapTotal
r.CPU.Model, r.CPU.Cores = cpuStatic()
r.Kernel = kernel()
r.Partitions = partitions()
}
}
func readProc(path string) string { b, _ := os.ReadFile(path); return string(b) }
func cpuSample() (idle, total uint64) {
f, err := os.Open("/proc/stat")
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
if sc.Scan() {
fields := strings.Fields(sc.Text()) // cpu user nice system idle iowait ...
for i, v := range fields[1:] {
n, _ := strconv.ParseUint(v, 10, 64)
total += n
if i == 3 { // idle
idle = n
}
}
}
return
}
func cpuUsage() float64 {
i1, t1 := cpuSample()
time.Sleep(100 * time.Millisecond)
i2, t2 := cpuSample()
dt := float64(t2 - t1)
if dt <= 0 {
return 0
}
return (1 - float64(i2-i1)/dt) * 100
}
func load1() float64 {
fields := strings.Fields(readProc("/proc/loadavg"))
if len(fields) > 0 {
v, _ := strconv.ParseFloat(fields[0], 64)
return v
}
return 0
}
func meminfo() (total, avail, swapTotal, swapFree uint64) {
f, err := os.Open("/proc/meminfo")
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 2 {
continue
}
kb, _ := strconv.ParseUint(fields[1], 10, 64)
b := kb * 1024
switch strings.TrimSuffix(fields[0], ":") {
case "MemTotal":
total = b
case "MemAvailable":
avail = b
case "SwapTotal":
swapTotal = b
case "SwapFree":
swapFree = b
}
}
return
}
func cpuStatic() (model string, cores int) {
f, err := os.Open("/proc/cpuinfo")
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "processor") {
cores++
} else if strings.HasPrefix(line, "model name") && model == "" {
if i := strings.Index(line, ":"); i >= 0 {
model = strings.TrimSpace(line[i+1:])
}
}
}
return
}
func kernel() string {
return strings.TrimSpace(readProc("/proc/sys/kernel/osrelease"))
}
func partitions() []pb.PartitionReport {
allowed := map[string]bool{"ext4": true, "xfs": true, "btrfs": true, "zfs": true, "vfat": true, "ntfs": true, "ext3": true}
f, err := os.Open("/proc/mounts")
if err != nil {
return nil
}
defer f.Close()
var out []pb.PartitionReport
seen := map[string]bool{}
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 3 || !allowed[fields[2]] || seen[fields[1]] {
continue
}
seen[fields[1]] = true
var st syscall.Statfs_t
if syscall.Statfs(fields[1], &st) != nil {
continue
}
total := st.Blocks * uint64(st.Bsize)
free := st.Bavail * uint64(st.Bsize)
out = append(out, pb.PartitionReport{
Device: fields[0], Mountpoint: fields[1], Fstype: fields[2],
TotalBytes: total, UsedBytes: total - free,
})
}
return out
}
```
- [ ] **Step 3: Non-linux stub (`collect_other.go`)**
```go
//go:build !linux
package inventory
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
// collect is a no-op best-effort stub on non-Linux platforms.
func collect(r *pb.InventoryReport, includeStatic bool) {}
```
- [ ] **Step 4: Verify build**
Run: `cd agent && go build ./... && go vet ./...`
Expected: success (build both native and, if convenient, `GOOS=windows go build ./...`).
- [ ] **Step 5: Commit**
```bash
git add agent/internal/inventory/
git commit -m "feat(agent): /proc-based inventory collectors"
```
---
## Task 4: Agent client method + scheduler
**Files:**
- Modify: `agent/internal/grpc/client.go`
- Modify: the agent main loop (`agent/cmd/main.go` or `agent/internal/sync/sync.go` — wherever the poll loop/tickers live).
**Interfaces:**
- Consumes: `inventory.Collect` (T3), pb (T1).
- Produces: `(*Client).ReportInventory(report *pb.InventoryReport) error`; a running ticker that reports metrics every 30s and static every 15 min.
- [ ] **Step 1: Add client method**
In `agent/internal/grpc/client.go`, mirroring `ReportUpdates`:
```go
func (c *Client) ReportInventory(report *pb.InventoryReport) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := c.client.ReportInventory(ctx, report)
return err
}
```
The report already carries `ServerId`/`AgentToken`; ensure the caller sets them (see Step 2).
- [ ] **Step 2: Add the scheduler to the agent loop**
Find where the agent starts its poll loop (the goroutine that calls `SyncKeys`/`ReportUpdates`). Add a parallel inventory ticker. `serverID`, `agentToken`, and the `*Client` are in scope there:
```go
go func() {
tick := 0
t := time.NewTicker(30 * time.Second)
defer t.Stop()
report := func(static bool) {
r := inventory.Collect(static)
r.ServerId = serverID
r.AgentToken = agentToken
if err := client.ReportInventory(r); err != nil {
log.Printf("report inventory: %v", err)
}
}
report(true) // send a full snapshot on startup
for range t.C {
tick++
report(tick%30 == 0) // every 30th tick = 15 min → include static
}
}()
```
Add imports `"github.com/mrhid6/vantage/agent/internal/inventory"`, `time`, `log` if missing. Match variable names to the actual loop (e.g. the client may be named `c`).
- [ ] **Step 3: Verify build**
Run: `cd agent && go build ./... && go vet ./...`
Expected: success.
- [ ] **Step 4: Commit**
```bash
git add agent/internal/grpc/client.go agent/
git commit -m "feat(agent): schedule inventory reporting (30s metrics, 15m static)"
```
---
## Task 5: Frontend — inventory panel on server detail
**Files:**
- Modify: `web/lib/api.ts` (extend the `Server`/server-detail type with `inventory`)
- Modify: `web/app/servers/[id]/page.tsx` (add panel; enable polling)
**Interfaces:**
- Consumes: server-detail query.
- [ ] **Step 1: Add the inventory type**
In `web/lib/api.ts`, add and attach to the server type used by the detail page:
```ts
export interface Inventory {
cpu: { model?: string; cores?: number; usage_pct: number; load1?: number };
memory: { total_bytes: number; used_bytes: number };
swap_total_bytes: number;
swap_used_bytes: number;
partitions?: { device: string; mountpoint: string; fstype?: string; total_bytes: number; used_bytes: number }[];
kernel?: string;
metrics_at?: string;
static_at?: string;
}
```
Add `inventory?: Inventory;` to the server detail interface.
- [ ] **Step 2: Add a `formatBytes` helper + Inventory panel**
In `web/app/servers/[id]/page.tsx`, add a helper and a panel component. Enable polling on the server-detail `useQuery` with `refetchInterval: 30000`.
```tsx
function formatBytes(n: number): string {
if (!n) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(1024));
return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`;
}
function UsageBar({ used, total }: { used: number; total: number }) {
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0;
return (
<div className="h-2 w-full overflow-hidden rounded-full bg-surface-2">
<div className={`h-full rounded-full ${pct > 90 ? "bg-danger" : "bg-accent"}`} style={{ width: `${pct}%` }} />
</div>
);
}
function InventoryPanel({ inv }: { inv: Inventory }) {
return (
<Card>
<h2 className="mb-4 text-lg font-semibold text-text-primary">Inventory</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">CPU</span><span className="text-text-primary">{inv.cpu.usage_pct.toFixed(0)}%</span></div>
<UsageBar used={inv.cpu.usage_pct} total={100} />
<p className="mt-1 text-xs text-text-secondary">{inv.cpu.model} · {inv.cpu.cores} cores · load {inv.cpu.load1?.toFixed(2)}</p>
</div>
<div>
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">Memory</span><span className="text-text-primary">{formatBytes(inv.memory.used_bytes)} / {formatBytes(inv.memory.total_bytes)}</span></div>
<UsageBar used={inv.memory.used_bytes} total={inv.memory.total_bytes} />
<div className="mb-1 mt-3 flex justify-between text-sm"><span className="text-text-secondary">Swap</span><span className="text-text-primary">{formatBytes(inv.swap_used_bytes)} / {formatBytes(inv.swap_total_bytes)}</span></div>
<UsageBar used={inv.swap_used_bytes} total={inv.swap_total_bytes} />
</div>
</div>
{inv.partitions && inv.partitions.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-medium text-text-secondary">Partitions</h3>
<div className="space-y-3">
{inv.partitions.map((p) => (
<div key={p.mountpoint}>
<div className="mb-1 flex justify-between text-xs">
<span className="font-mono text-text-primary">{p.mountpoint}</span>
<span className="text-text-secondary">{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)} · {p.fstype}</span>
</div>
<UsageBar used={p.used_bytes} total={p.total_bytes} />
</div>
))}
</div>
</div>
)}
{inv.kernel && <p className="mt-4 text-xs text-text-secondary">Kernel {inv.kernel}</p>}
</Card>
);
}
```
Render `{server.inventory && <InventoryPanel inv={server.inventory} />}` in the page body (ensure `Card`, `Inventory` are imported). Match how the page currently reads the server object.
- [ ] **Step 3: Verify build**
Run: `cd web && npm run build`
Expected: success.
- [ ] **Step 4: Commit**
```bash
git add web/lib/api.ts web/app/servers/[id]/page.tsx
git commit -m "feat(web): inventory panel on server detail"
```
---
## Task 6: End-to-end manual verification
- [ ] **Step 1: Build all**
Run: `cd server && go build ./... && cd ../agent && go build ./... && cd ../web && npm run build`
Expected: all succeed.
- [ ] **Step 2: Smoke (if environment available)**
With server + Mongo + a connected Linux agent: within ~30s the server detail page shows CPU %, RAM/swap bars; within 15 min (or on agent restart, which sends a full snapshot immediately) partitions, CPU model and kernel appear. Confirm metrics update roughly every 30s.
- [ ] **Step 3: Commit any fixes**
```bash
git add -A
git commit -m "fix: fleet inventory verification fixes"
```
---
# Service Monitoring (uptime-kuma replacement)
Extends the fleet work: in-app service monitors replacing uptime-kuma. Monitors (HTTP/TCP/ICMP/TLS) run **server-side** (public endpoints) or **agent-side** (agent probes its own host). Both runners feed one server-side ingest pipeline: state → incidents → rollups → notifications.
**Design:** validated in brainstorm 2026-07-21. Hybrid runners, all 4 check types, latest+incidents+rollups history, multi-channel notify (webhook/SMTP/Discord/Slack/Telegram), dedicated `SyncMonitors`/`ReportChecks` RPCs.
**Build order — 3 phases, each shippable:**
- **P1 (Tasks 7–10):** data model, checker pkg, server scheduler, ingest pipeline, `/monitors` UI. Server-run only. No agent, no notify.
- **P2 (Tasks 11–12):** `SyncMonitors` + `ReportChecks` RPCs, agent checker + scheduler, agent-run monitors bound to a server.
- **P3 (Tasks 13–14):** notification channels + dispatch + settings UI.
## Monitoring Global Constraints
- Same as fleet: no tests this iteration; verify with `go build ./...`, `go vet ./...`, `npm run build`. JSON-codec gRPC — edit both pb files identically, mirror `ReportUpdates` wiring. Separate Go modules, so the checker pkg is **duplicated** in `server/` and `agent/` (same convention as pb files).
- Reuse existing patterns: REST handlers like `server/internal/api`, services like `server/internal/services/servers.go`, `db.Col(...)`, react-query + Tailwind UI like `web/app/servers`.
---
## Task 7: Monitoring data model + checker package (server)
**Files:**
- Create: `server/internal/models/monitor.go`
- Create: `server/internal/checker/checker.go` (+ `http.go`, `tcp.go`, `icmp.go`, `tls.go`)
**Interfaces:**
- Produces: `models.Monitor` (+ `MonitorState`, `MonitorTarget`), `models.Incident`, `models.Rollup`. `checker.Run(ctx, models.Monitor) checker.Result` where `Result{Up bool; LatencyMs int; Message string; CertExpiry *time.Time}`.
- [ ] **Step 1: Model**
```go
type MonitorTarget struct {
URL string `bson:"url,omitempty" json:"url,omitempty"`
Host string `bson:"host,omitempty" json:"host,omitempty"`
Port int `bson:"port,omitempty" json:"port,omitempty"`
Method string `bson:"method,omitempty" json:"method,omitempty"`
ExpectedStatus int `bson:"expected_status,omitempty" json:"expected_status,omitempty"`
Keyword string `bson:"keyword,omitempty" json:"keyword,omitempty"`
TLSWarnDays int `bson:"tls_warn_days,omitempty" json:"tls_warn_days,omitempty"`
}
type MonitorState struct {
Status string `bson:"status" json:"status"` // up|down|pending
LastCheckAt *time.Time `bson:"last_check_at,omitempty" json:"last_check_at,omitempty"`
LatencyMs int `bson:"latency_ms" json:"latency_ms"`
Message string `bson:"message,omitempty" json:"message,omitempty"`
CertExpiryAt *time.Time `bson:"cert_expiry_at,omitempty" json:"cert_expiry_at,omitempty"`
Fails int `bson:"fails" json:"fails"` // consecutive failures
}
type Monitor struct {
MonitorID string `bson:"monitor_id" json:"monitor_id"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"` // http|tcp|icmp|tls
Target MonitorTarget `bson:"target" json:"target"`
IntervalSec int `bson:"interval_sec" json:"interval_sec"`
Runner string `bson:"runner" json:"runner"` // "server" or a server_id
Retries int `bson:"retries" json:"retries"` // consecutive fails before down
Enabled bool `bson:"enabled" json:"enabled"`
ChannelIDs []string `bson:"channel_ids,omitempty" json:"channel_ids,omitempty"`
State MonitorState `bson:"state" json:"state"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type Incident struct {
IncidentID string `bson:"incident_id" json:"incident_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
ResolvedAt *time.Time `bson:"resolved_at,omitempty" json:"resolved_at,omitempty"`
Cause string `bson:"cause,omitempty" json:"cause,omitempty"`
}
type Rollup struct {
MonitorID string `bson:"monitor_id" json:"monitor_id"`
PeriodStart time.Time `bson:"period_start" json:"period_start"` // hour bucket
Checks int `bson:"checks" json:"checks"`
UpCount int `bson:"up_count" json:"up_count"`
SumLatency int64 `bson:"sum_latency" json:"sum_latency"`
}
```
- [ ] **Step 2: Checker package** — `Run(ctx, m)` switches on `m.Type`:
- **http**: `http.Client` GET/HEAD `m.Target.URL`, assert status == ExpectedStatus (default 200), optional `Keyword` body contains; capture TLS peer cert expiry when https.
- **tcp**: `net.DialTimeout("tcp", host:port)`, latency = dial time.
- **icmp**: raw ICMP echo (agent/server run as root). Fall back to `net.Dial("ip4:icmp")`; on permission error return down with message.
- **tls**: `tls.Dial`, read `ConnectionState().PeerCertificates[0].NotAfter` → `CertExpiry`; down if within `TLSWarnDays` or expired.
- All: wrap with per-check timeout (min(IntervalSec, 10s)); `Result.Message` = short reason on failure.
- [ ] **Step 3: Verify build** — `cd server && go build ./... && go vet ./...`
- [ ] **Step 4: Commit** — `feat(server): monitor model + checker package`
---
## Task 8: Ingest pipeline + rollups service
**Files:**
- Create: `server/internal/services/monitors.go`
**Interfaces:**
- Produces: `IngestResult(monitorID string, res checker.Result) error` — the single entry both runners use. `ListMonitors`, `GetMonitor`, `CreateMonitor`, `UpdateMonitor`, `DeleteMonitor`, `ListIncidents(monitorID)`, `UptimeRollups(monitorID, since)`.
- [ ] **Step 1: `IngestResult`** — load monitor; compute new status with `Retries` threshold (increment `state.Fails` on failure, flip to `down` only when `Fails >= Retries`; reset + flip `up` on success). On **transition**: open incident (`down`) or resolve open incident (`up`), and enqueue notification (P3 — leave a `// TODO(P3): dispatch` hook now). Always `$set` state fields. Upsert current-hour `Rollup` (`$inc` checks/up_count/sum_latency). Use `db.Col("monitors")`, `db.Col("incidents")`, `db.Col("monitor_rollups")`, `context.WithTimeout`.
- [ ] **Step 2: CRUD + queries** — standard service funcs mirroring `services/servers.go`. `UptimeRollups` aggregates buckets since a cutoff → uptime % + avg latency series.
- [ ] **Step 3: Verify build** — `go build ./... && go vet ./...`
- [ ] **Step 4: Commit** — `feat(server): monitor ingest pipeline, incidents, rollups`
---
## Task 9: Server scheduler + REST API
**Files:**
- Create: `server/internal/monitorsched/scheduler.go`
- Create: `server/internal/api/monitors.go`
- Modify: server bootstrap (wherever services/gRPC start) to launch the scheduler; router registration where `api` routes are mounted.
**Interfaces:**
- Produces: a scheduler that ticks enabled `runner=="server"` monitors on their `IntervalSec` and calls `checker.Run` → `services.IngestResult`. REST: `GET/POST /api/monitors`, `GET/PUT/DELETE /api/monitors/:id`, `GET /api/monitors/:id/incidents`, `GET /api/monitors/:id/uptime`.
- [ ] **Step 1: Scheduler** — on boot load monitors; per-monitor goroutine or a min-heap wheel keyed on next-run. Only `runner=="server"`. Reload on CRUD (simplest: re-read every N sec, or a reload channel fired by the service). Skip disabled.
- [ ] **Step 2: REST handlers** — mirror an existing `server/internal/api` handler file for style + auth middleware. JSON in/out of `models.Monitor`.
- [ ] **Step 3: Verify build** — `go build ./... && go vet ./...`
- [ ] **Step 4: Commit** — `feat(server): server-run monitor scheduler + REST API`
---
## Task 10: Frontend — monitors UI (P1)
**Files:**
- Modify: `web/lib/api.ts` (Monitor types + bindings)
- Create: `web/app/monitors/page.tsx` (list), `web/app/monitors/[id]/page.tsx` (detail), `web/app/monitors/new/page.tsx` (create/edit form)
- Modify: main nav to add **Monitors** (same place Steps was added)
**Interfaces:**
- Consumes: `/api/monitors*` (T9).
- [ ] **Step 1: Types + api bindings** — `Monitor`, `MonitorState`, `Incident`, uptime series; `api.monitors.list/get/create/update/remove/incidents/uptime`.
- [ ] **Step 2: List page** — table: name, type, status badge (up/down/pending), uptime % (24h), latency, last check. `refetchInterval: 30000`.
- [ ] **Step 3: Detail page** — status header, heartbeat/uptime bars (24h + 30d from rollups), latency chart, incident timeline, cert expiry, assigned channels (read-only until P3).
- [ ] **Step 4: Create/edit form** — type-dependent fields (URL vs host/port), interval, retries, runner select (`server` or a registered server for agent-run — server option only wired in P2), enabled.
- [ ] **Step 5: Verify build** — `cd web && npm run build`
- [ ] **Step 6: Commit** — `feat(web): monitors list/detail/form UI`
---
## Task 11: SyncMonitors + ReportChecks RPCs (P2)
**Files:**
- Modify: `proto/vantage/v1/vantage.proto`, `server/internal/grpc/pb/vantage.pb.go`, `agent/internal/grpc/pb/vantage.pb.go`, `server/internal/grpc/server.go`, `agent/internal/grpc/client.go`
**Interfaces:**
- Produces: `SyncMonitors(server_id, agent_token) -> repeated MonitorSpec`; `ReportChecks(server_id, agent_token, repeated CheckResult) -> ReportChecksResponse`. `MonitorSpec{monitor_id, type, target fields, interval_sec, retries}`. `CheckResult{monitor_id, up, latency_ms, message, cert_expiry_unix}`.
- [ ] **Step 1: pb structs + proto** — add messages to both pb files + proto doc.
- [ ] **Step 2: Wire both RPCs** — mirror `ReportUpdates` plumbing (interface, Unimplemented stub, client method, `Vantage_ServiceDesc.Methods`, `_Vantage_*_Handler`) in both pb files. Server handlers on `vantageServer` (after `ReportUpdates` at server.go:78): `SyncMonitors` returns monitors where `runner==req.ServerId && enabled`; `ReportChecks` validates token then loops `services.IngestResult`. Client methods on `*Client` in client.go (after `ReportUpdates` at client.go:117).
- [ ] **Step 3: Verify build** — both modules `go build ./... && go vet ./...`
- [ ] **Step 4: Commit** — `feat(proto): SyncMonitors + ReportChecks RPCs`
---
## Task 12: Agent checker + scheduler (P2)
**Files:**
- Create: `agent/internal/checker/` (duplicate of server checker pkg)
- Create: `agent/internal/monitors/monitors.go` (poll + run + report loop)
- Modify: agent main loop to start it (alongside the sync loop in `agent/internal/sync` / the inventory ticker from Task 4)
**Interfaces:**
- Consumes: `client.SyncMonitors`, `client.ReportChecks`, agent `checker`.
- [ ] **Step 1: Duplicate checker pkg** into agent module (identical logic; imports agent pb).
- [ ] **Step 2: Monitor loop** — poll `SyncMonitors` every 30s for assigned specs; per-spec ticker on `IntervalSec` runs `checker.Run`; batch `CheckResult`s and `ReportChecks`. `serverID`/`agentToken`/`*Client` in scope from the existing loop.
- [ ] **Step 3: Verify build** — `cd agent && go build ./... && go vet ./...` (+ `GOOS=windows go build ./...`; icmp may no-op on Windows).
- [ ] **Step 4: Commit** — `feat(agent): agent-run monitor scheduler`
---
## Task 13: Notification channels + dispatch (P3)
**Files:**
- Create: `server/internal/models/channel.go`, `server/internal/services/channels.go`, `server/internal/notify/` (`dispatch.go`, `webhook.go`, `smtp.go`, `discord.go`, `slack.go`, `telegram.go`), `server/internal/api/channels.go`
- Modify: `server/internal/services/monitors.go` (replace the P2 `// TODO(P3): dispatch` hook)
**Interfaces:**
- Produces: `models.NotificationChannel{channel_id, name, type, config map, enabled}`. `notify.Dispatch(channel, event)` where `event` = monitor + old/new status + message. `notify.Test(channel)`.
- [ ] **Step 1: Model + CRUD service + REST** (`/api/channels*`, incl. `POST /api/channels/:id/test`).
- [ ] **Step 2: Dispatch abstraction** — webhook/discord/slack/telegram are HTTP POST with per-type JSON payload; SMTP via `net/smtp`. Per-monitor routing via `monitor.ChannelIDs`; resend interval so an ongoing `down` re-alerts at most every N min (track `last_notified_at` on monitor state).
- [ ] **Step 3: Fire on transition** — in `IngestResult`, on up/down flip resolve channels and `notify.Dispatch` each (goroutine, best-effort, log failures).
- [ ] **Step 4: Verify build** — `go build ./... && go vet ./...`
- [ ] **Step 5: Commit** — `feat(server): multi-channel monitor notifications`
---
## Task 14: Frontend — notification settings (P3)
**Files:**
- Modify: `web/lib/api.ts` (channel types + bindings), `web/app/settings/` (add notifications section/page)
- Modify: monitor create/edit form (Task 10) to select channels
**Interfaces:**
- Consumes: `/api/channels*`.
- [ ] **Step 1: Channel types + api bindings.**
- [ ] **Step 2: Settings UI** — list/add/edit channels, type-dependent config fields, **Test** button hitting `/api/channels/:id/test`.
- [ ] **Step 3: Wire channel multi-select** into the monitor form.
- [ ] **Step 4: Verify build** — `cd web && npm run build`
- [ ] **Step 5: Commit** — `feat(web): notification channel settings UI`
---
## Self-Review Notes
- **Spec coverage:** §3 model → T1; §4 RPC → T1; §5 collectors + scheduler → T3, T4; §6 handler/store → T2; §7 frontend → T5. Split cadence (30s metrics / 15m static) in T4 scheduler; merge rules preserving static in T2 `StoreInventory`. Tests omitted per Global Constraints.
- **Startup snapshot:** agent sends `Collect(true)` immediately so static fields populate without waiting 15 min.
- **Types consistent:** `InventoryReport` field names identical across proto, both pb files, store service, and TS interface (`usage_pct`, `used_bytes`, `total_bytes`, `swap_*`).
- **Follow-ups (out of scope):** time-series history, usage alerting, Windows collectors, servers-list CPU/RAM badges.
- **Monitoring (Tasks 7–14):** hybrid runner service-monitor replacing uptime-kuma, added 2026-07-21. 3 phases — P1 server-run engine+UI (T7–10), P2 agent-run RPCs (T11–12), P3 multi-channel notify (T13–14). Single `IngestResult` pipeline for both runners; checker pkg duplicated per module (pb convention). Design: brainstorm 2026-07-21. Follow-ups out of scope: status pages, maintenance windows, per-check auth headers, ICMP on Windows.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,142 @@
# Fleet Inventory — Design
**Date:** 2026-07-20
**Status:** Approved (design) — ready for implementation planning
**Scope:** Fleet Inventory only. Server Workflows and SaaS/auth are separate sub-projects.
---
## 1. Summary
Each agent collects hardware/OS inventory about its host and reports it to the server, which stores the latest snapshot per server and surfaces it in the UI. Two cadences:
- **Metrics (near-real-time):** CPU load/usage, RAM used/total, swap used/total — every **30s** (aligned with existing poll rhythm).
- **Static inventory (slow):** disks, partitions and their usage, CPU model/cores, total RAM, OS details — every **15 min**.
Transport: a **new unary gRPC `ReportInventory` RPC** (mirrors the existing `ReportUpdates` pattern). No streaming.
---
## 2. Locked decisions
| Topic | Decision |
|-------|----------|
| Transport | New `ReportInventory` unary RPC. |
| Cadence | Metrics every 30s; static inventory every 15 min. One RPC carries both, but static fields are only populated on the 15-min tick (empty/omitted otherwise → server keeps prior static snapshot). |
| Storage | Latest snapshot embedded on the `servers` document (`inventory` sub-doc). No history/time-series in v1. |
| Collection | Pure-Go where practical (`/proc`, `gopsutil`-style). Agent already runs as root. |
| Platform | Linux primary; Windows agent populates what it can, leaves the rest empty. |
---
## 3. Data model
Add an `Inventory` sub-document to the existing `Server` model (`server/internal/models/server.go`):
```go
type CPUInfo struct {
Model string `bson:"model,omitempty" json:"model,omitempty"`
Cores int `bson:"cores,omitempty" json:"cores,omitempty"`
UsagePct float64 `bson:"usage_pct" json:"usage_pct"` // metrics tick
Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"`
}
type MemInfo struct {
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"` // metrics tick
}
type Partition struct {
Device string `bson:"device" json:"device"`
Mountpoint string `bson:"mountpoint" json:"mountpoint"`
Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"`
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
}
type Inventory struct {
CPU CPUInfo `bson:"cpu" json:"cpu"`
Memory MemInfo `bson:"memory" json:"memory"`
SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"`
SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"`
Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"`
Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"`
MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
}
```
Add `Inventory *Inventory` field to `Server`.
Server-side update rules:
- Metrics fields (`cpu.usage_pct`, `cpu.load1`, `memory.used_bytes`, swap used) always updated + `metrics_at`.
- Static fields (`cpu.model/cores`, `memory.total_bytes`, `partitions`, `kernel`, swap total) updated only when the report includes them (non-zero/non-empty) + `static_at`.
---
## 4. gRPC protocol (`proto/vantage/v1/vantage.proto` + both `pb.go` files)
```protobuf
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
message InventoryReport {
string server_id = 1;
string agent_token = 2;
bool include_static = 3; // true on the 15-min tick
CPUReport cpu = 4;
MemReport memory = 5;
uint64 swap_total = 6;
uint64 swap_used = 7;
repeated PartitionReport partitions = 8; // only when include_static
string kernel = 9; // only when include_static
}
message CPUReport { string model = 1; int32 cores = 2; double usage_pct = 3; double load1 = 4; }
message MemReport { uint64 total_bytes = 1; uint64 used_bytes = 2; }
message PartitionReport { string device = 1; string mountpoint = 2; string fstype = 3; uint64 total_bytes = 4; uint64 used_bytes = 5; }
message InventoryReportResponse {}
```
Hand-written JSON-codec structs added to `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go`, plus the RPC method wiring (service interface, client method, handler registration) mirroring `ReportUpdates`.
---
## 5. Agent collection (`agent/internal/inventory/`)
- `Collect(includeStatic bool) *pb.InventoryReport` — reads:
- CPU usage: sample `/proc/stat` delta; load from `/proc/loadavg`; model/cores from `/proc/cpuinfo` (static).
- Memory/swap: `/proc/meminfo`.
- Partitions: `/proc/mounts` filtered to real filesystems + `statfs` for total/used (static).
- Kernel: `uname` / `/proc/version` (static).
- Windows: best-effort via `wmic`/PS or leave empty.
- Scheduler in the agent main loop: a 30s ticker calls `Collect(false)` and `ReportInventory`; every 30th tick (15 min) calls `Collect(true)`.
- Reuse existing gRPC client; add `Client.ReportInventory(...)` like `ReportUpdates`.
Prefer implementing the `/proc` readers directly (no new heavy deps) unless a `gopsutil` dependency is already vendored.
---
## 6. Server handler + service
- gRPC handler `ReportInventory` in `server/internal/grpc/server.go`: validate agent token (`ValidateAgentToken`), then call `services.StoreInventory(serverID, report)`.
- `services.StoreInventory` (in `server/internal/services/inventory.go`): builds the `$set` per the update rules in §3 and `UpdateOne` on `servers`.
---
## 7. Frontend
Surface inventory on the existing server detail page (`web/app/servers/[id]/page.tsx`) — add an "Inventory" panel:
- CPU usage gauge + model/cores, load.
- RAM used/total bar, swap bar.
- Partitions table: device, mount, fstype, used/total with a usage bar.
- "Updated Xs ago" from `metrics_at`/`static_at`.
Optionally add compact CPU/RAM badges to the servers list (`web/app/servers/page.tsx`). Reuse `@/components/ui` + Tailwind tokens. Poll the server detail query while the page is open (react-query `refetchInterval` ~30s) so metrics stay fresh.
---
## 8. Out of scope
- Time-series history / graphs (only latest snapshot stored).
- Alerting thresholds on usage (settings/alerts is a separate concern).
- Per-process / network / GPU inventory.
- Tests (skipped, consistent with the Workflows iteration).
@@ -0,0 +1,142 @@
# SaaS: Auth + Organizations — Design
**Date:** 2026-07-20
**Status:** Approved (design) — ready for implementation planning
**Scope:** Local auth + organizations + per-org OIDC, and org-scoping of existing data. Billing/plan-limits explicitly deferred. Fleet Inventory and Server Workflows are separate sub-projects.
---
## 1. Summary
Turn Vantage from a single-admin, single global-OIDC tool into a multi-tenant app:
1. **Replace** the global Authentik/env-based OIDC with **local email/password accounts** as the primary login.
2. **Organizations** — every user belongs to an org; every domain object (servers, keys, secrets, assignments, workflows, steps, runs, audit) carries an `org_id` and all queries are scoped to the caller's org.
3. **Per-org OpenID** — an org admin can configure their own OIDC provider (issuer/client id/secret); users in that org can then sign in through it.
No billing, no seat/server limits this iteration (schema leaves room).
---
## 2. Locked decisions
| Topic | Decision |
|-------|----------|
| Primary auth | Local email + password (bcrypt). Replaces global Authentik. |
| Org SSO | Per-org OIDC provider, configured by org admin, resolved dynamically at login. |
| Isolation | `org_id` on every collection; every service query filtered by org. Enforced in the request layer via session→org. |
| Roles | `owner`, `admin`, `member` (v1: owner/admin can manage users + org OIDC + all resources; member can use resources). Keep minimal. |
| Bootstrapping | First-run creates the initial org + owner account (setup flow) when no users exist. |
| Sessions | Keep existing Redis session store; session now carries `user_id`, `org_id`, `role`, `email`. |
| Agent auth | Unchanged (per-server agent tokens). Servers gain `org_id`; agent RPCs resolve org from the server record. |
---
## 3. Data model
### `orgs`
```json
{ "_id":"ObjectId", "org_id":"uuid", "name":"Acme", "created_at":"ISODate" }
```
### `users`
```json
{
"_id":"ObjectId", "user_id":"uuid", "org_id":"uuid",
"email":"a@b.com", "password_hash":"bcrypt...", "role":"owner|admin|member",
"auth_source":"local|oidc", "created_at":"ISODate", "last_login":"ISODate|null"
}
```
Unique index on `email` (global — email identifies the account and its org).
### `org_oidc` (per-org provider config)
```json
{
"_id":"ObjectId", "org_id":"uuid",
"issuer":"https://id.acme.com", "client_id":"...",
"client_secret_enc":"AES...", // encrypted with existing crypto.go
"redirect_url":"https://vantage.../auth/oidc/callback",
"enabled": true, "updated_at":"ISODate"
}
```
### Existing collections — add `org_id`
`servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` each gain `org_id string`. A **migration** backfills all existing documents into a default org (see §7).
---
## 4. Auth flows
### Local
- `POST /auth/register` — only allowed during first-run bootstrap (creates org + owner) OR by an org admin inviting a user (see below). Not open self-serve.
- `POST /auth/login` — email + password → verify bcrypt → create session with `{user_id, org_id, role, email}`.
- `POST /auth/logout` — destroy session.
- `GET /auth/me` — returns current user + org.
### Org-admin user management
- `GET /api/org/users` / `POST /api/org/users` (create local user in caller's org) / `PUT /api/org/users/:id/role` / `DELETE /api/org/users/:id`.
### Per-org OIDC
- `GET/PUT /api/org/oidc` — read/save the caller org's provider config (admin only). Secret stored encrypted.
- `GET /auth/oidc/start?org=<org_id or slug>` — look up org's `org_oidc`, build the OIDC provider on demand (cache per org), redirect to authorize.
- `GET /auth/oidc/callback` — exchange code, match/provision the user by email **within that org**, create session.
- If the email exists in the org → log in. If not → provision a `member` with `auth_source=oidc` (org admin can promote). Reject if email belongs to a different org.
### First-run bootstrap
- `GET /auth/bootstrap-status` → `{ needs_setup: bool }` (true when `users` is empty).
- Setup page collects org name + owner email/password → creates org + owner → session.
---
## 5. Request scoping
- `auth.Middleware` already loads the session; extend `Session` to include `OrgID`, `UserID`, `Role`. Add helper `auth.OrgID(c) string`.
- **Every service function that reads/writes a scoped collection takes an `orgID` argument** and adds `"org_id": orgID` to its filter and on insert. Handlers pass `auth.OrgID(c)`.
- Add a `requireRole(role)` gin middleware for admin-only routes (org user mgmt, org OIDC).
- Agent-facing gRPC: resolve `org_id` from the `servers` record (already tied to `server_id`); inventory/keys/sync operate on that org implicitly.
---
## 6. Removing global Authentik
- Delete/retire env-driven `InitOIDC` global provider (`OIDC_ISSUER` etc.). Keep the `go-oidc`/`oauth2` machinery but move it behind the per-org resolver.
- `authEnabled` global replaced by "auth always on" (there is always local auth). Update `middleware.go` accordingly (no more `if !authEnabled { next }` bypass — except the bootstrap endpoints and login/register which are unauthenticated).
- Login page (`web/app/login` or existing) offers: email/password form + "Sign in with your organization's SSO" (enter org, redirect to `/auth/oidc/start`).
---
## 7. Migration
One-shot migration run at startup (idempotent):
1. If `orgs` is empty AND `servers`/`keys`/etc. contain documents without `org_id`: create a **default org** ("Default").
2. Set `org_id = <default>` on all existing `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` documents missing it.
3. If `OIDC_ISSUER` env was set previously and an admin email is known, optionally seed an owner user (documented manual step) — otherwise first-run bootstrap handles owner creation.
Guard with a marker (e.g. a `migrations` collection entry) so it runs once.
---
## 8. Frontend
- **Login/Setup:** `web/app/login/page.tsx` (email/password + org SSO entry) and `web/app/setup/page.tsx` (first-run). Redirect logic based on `bootstrap-status` and `auth/me`.
- **Org settings:** `web/app/settings/org/` — members list + invite/create user + role management; OIDC provider form (issuer/client id/secret/enabled).
- Existing pages unchanged functionally but now implicitly org-scoped by the backend. Show current org + user in the sidebar/header.
---
## 9. Security
- Passwords: bcrypt (cost ≥ 12). Never returned.
- Org OIDC client secret encrypted at rest (reuse `services/crypto.go` AES).
- Cross-org access prevented at the service layer (org_id in every filter) — the primary isolation boundary. Handlers must never accept an `org_id` from the client; always derive from session.
- OIDC callback must bind the returned identity to the org that initiated the flow (state carries org_id) to prevent org-mixing.
- Role checks on all org-admin mutations.
---
## 10. Out of scope
- Billing, plans, seat/server limits.
- Cross-org resource sharing, org switching for a single user (one user = one org in v1).
- SCIM / directory sync, SAML.
- Email delivery for invites (create-user sets a password or invite token; email sending deferred — document as manual/console output).
- Tests (skipped, consistent with prior iterations).
Binary file not shown.
+140
View File
@@ -0,0 +1,140 @@
param(
[string]$ServerId,
[string]$Token,
[string]$ServerUrl,
[string]$InstallDir,
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$logDir = Join-Path $env:ProgramData "vantage"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$log = Join-Path $logDir "install.log"
function Write-Log($msg) {
$line = "{0} {1}" -f (Get-Date -Format "s"), $msg
Add-Content -Path $log -Value $line
}
# Fail native-exe (nssm) calls loudly: check $LASTEXITCODE after each call
function Invoke-Native {
param([string]$File, [string[]]$Arguments)
Write-Log ("RUN: {0} {1}" -f $File, ($Arguments -join " "))
$out = & $File @Arguments 2>&1
if ($out) { Write-Log ("OUT: {0}" -f ($out -join "`n")) }
if ($LASTEXITCODE -ne 0) {
throw ("{0} exited {1}" -f $File, $LASTEXITCODE)
}
}
# Like Invoke-Native but never throws — for teardown, where a missing/stopped
# service must not abort the uninstall.
function Invoke-NativeSoft {
param([string]$File, [string[]]$Arguments)
Write-Log ("RUN(soft): {0} {1}" -f $File, ($Arguments -join " "))
# Native stderr merged via 2>&1 becomes terminating errors under
# ErrorActionPreference=Stop; force Continue in this scope so a benign nssm
# message (e.g. "service has not been started") never aborts setup.
$ErrorActionPreference = "Continue"
$out = & $File @Arguments 2>&1
if ($out) { Write-Log ("OUT: {0}" -f ($out -join "`n")) }
Write-Log ("EXIT: {0}" -f $LASTEXITCODE)
}
if ($Uninstall) {
try {
Write-Log "=== teardown start ==="
if (-not $InstallDir) { $InstallDir = $PSScriptRoot }
$nssm = Join-Path $InstallDir "nssm.exe"
if (Test-Path $nssm) {
Invoke-NativeSoft -File $nssm -Arguments @("stop", "VantageAgent")
Invoke-NativeSoft -File $nssm -Arguments @("remove", "VantageAgent", "confirm")
} else {
Write-Log "nssm.exe not found at $nssm - using sc.exe fallback"
Invoke-NativeSoft -File "sc.exe" -Arguments @("stop", "VantageAgent")
Invoke-NativeSoft -File "sc.exe" -Arguments @("delete", "VantageAgent")
}
Write-Log "=== teardown ok ==="
exit 0
}
catch {
Write-Log ("TEARDOWN ERROR: {0}" -f $_.Exception.Message)
# Never block uninstall
exit 0
}
}
try {
Write-Log "=== setup start ==="
Write-Log ("ServerId={0} ServerUrl={1} InstallDir={2}" -f $ServerId, $ServerUrl, $InstallDir)
$cfgDir = Join-Path $env:ProgramData "vantage"
New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null
$cfgPath = Join-Path $cfgDir "config.yaml"
# Preserve existing config on upgrade. A MajorUpgrade re-runs this script with
# no SERVERID/TOKEN, so blindly rewriting would wipe the agent_token the agent
# persisted after Register(). Only (re)write when a ServerId is supplied
# (fresh install / explicit re-register).
if ((Test-Path $cfgPath) -and (-not $ServerId)) {
Write-Log "config.yaml exists and no ServerId supplied - preserving existing config (upgrade)"
}
else {
$cfg = @"
server_url: "$ServerUrl"
server_id: "$ServerId"
pre_reg_token: "$Token"
agent_token: ""
poll_interval: 30s
tls: true
"@
Set-Content -Path $cfgPath -Value $cfg -Encoding utf8
Write-Log "wrote $cfgPath"
# Lock down ACL: SYSTEM + Administrators only
Invoke-Native -File "icacls" -Arguments @($cfgPath, "/inheritance:r", "/grant:r", "SYSTEM:F", "Administrators:F")
}
if (-not $InstallDir) { $InstallDir = $PSScriptRoot }
$nssm = Join-Path $InstallDir "nssm.exe"
$exe = Join-Path $InstallDir "vantage-agent.exe"
if (-not (Test-Path $nssm)) { throw "nssm.exe not found at $nssm" }
if (-not (Test-Path $exe)) { throw "vantage-agent.exe not found at $exe" }
# Install only if the service isn't already registered (an upgrade may leave
# it in place). "nssm install" on an existing service errors otherwise.
$exists = Get-Service -Name "VantageAgent" -ErrorAction SilentlyContinue
if (-not $exists) {
Invoke-Native -File $nssm -Arguments @("install", "VantageAgent", $exe)
} else {
Write-Log "VantageAgent service already exists - updating binary path"
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "Application", $exe)
}
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "Start", "SERVICE_AUTO_START")
# Redirect service stdout/stderr to log files (nssm discards them otherwise)
# with online rotation at ~1MB.
$outLog = Join-Path $logDir "agent-stdout.log"
$errLog = Join-Path $logDir "agent-stderr.log"
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStdout", $outLog)
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStderr", $errLog)
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStdoutCreationDisposition", "4")
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStderrCreationDisposition", "4")
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateFiles", "1")
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateOnline", "1")
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateBytes", "1048576")
# Service is freshly (re)installed and stopped here (teardown removed the old
# one on upgrade), so start it. "restart" would try to stop a not-running
# service and emit a stderr error.
Invoke-NativeSoft -File $nssm -Arguments @("start", "VantageAgent")
Write-Log "=== setup ok ==="
exit 0
}
catch {
Write-Log ("ERROR: {0}" -f $_.Exception.Message)
Write-Log ($_.ScriptStackTrace)
exit 1
}
+82
View File
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<?ifndef Version ?>
<?define Version = "0.0.0.0" ?>
<?endif?>
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package Name="Vantage Agent" Manufacturer="Vantage"
Version="$(var.Version)" UpgradeCode="7d1e6d2c-2a5f-4b3e-9c3a-8a1b2c3d4e5f"
Scope="perMachine">
<MajorUpgrade DowngradeErrorMessage="A newer version is already installed."
Schedule="afterInstallInitialize" />
<MediaTemplate EmbedCab="yes" />
<!-- Public properties settable via msiexec: SERVERID, TOKEN, SERVERURL -->
<Property Id="SERVERID" Secure="yes" />
<Property Id="TOKEN" Secure="yes" />
<Property Id="SERVERURL" Secure="yes" />
<StandardDirectory Id="ProgramFiles64Folder">
<Directory Id="INSTALLDIR" Name="Vantage">
<Component Id="AgentExe" Guid="*">
<File Id="AgentExe" Source="vantage-agent-windows-amd64.exe" Name="vantage-agent.exe" KeyPath="yes" />
</Component>
<Component Id="NssmExe" Guid="*">
<File Id="NssmExe" Source="nssm.exe" Name="nssm.exe" KeyPath="yes" />
</Component>
<Component Id="SetupScript" Guid="*">
<File Id="SetupScript" Source="setup.ps1" Name="setup.ps1" KeyPath="yes" />
</Component>
</Directory>
</StandardDirectory>
<Feature Id="Main">
<ComponentRef Id="AgentExe" />
<ComponentRef Id="NssmExe" />
<ComponentRef Id="SetupScript" />
</Feature>
<!-- Write config.yaml, then install + start the service via nssm.
Implemented as sequenced CustomActions running a helper script.
Deferred CustomActions run out-of-process (and with Impersonate="no",
as SYSTEM) with NO access to the installer property table, so
"[SERVERID]"/"[TOKEN]"/"[SERVERURL]"/"[INSTALLDIR]" would resolve to
empty strings if referenced directly on the deferred action. The fix
is the standard CustomActionData marshaling pattern: an immediate
SetProperty (type 51) with the SAME Id as the deferred CustomAction
runs first (while property values are still visible) and resolves
the formatted string; the deferred Directory/ExeCommand CustomAction
that shares that Id then receives the resolved string back as its
CustomActionData, referenced here as "[WriteConfig]". This avoids
pulling in the WixToolset.Util extension (WixQuietExec64) purely to
get CustomActionData plumbing.
NOTE: this only builds/validates the MSI's XML in CI - it has not
been verified with a real install on Windows. Needs a smoke test
(msiexec /i, confirm C:\ProgramData\Vantage\config.yaml or similar
is written with the correct values, and the service starts) on an
actual Windows machine before this is trusted in production. -->
<SetProperty Id="WriteConfig"
Before="WriteConfig" Sequence="execute" Condition="NOT Installed"
Value='cmd.exe /c powershell -ExecutionPolicy Bypass -File "[INSTALLDIR]setup.ps1" -ServerId "[SERVERID]" -Token "[TOKEN]" -ServerUrl "[SERVERURL]"' />
<CustomAction Id="WriteConfig" Directory="INSTALLDIR" ExeCommand="[WriteConfig]"
Execute="deferred" Impersonate="no" Return="check" />
<!-- Teardown on uninstall: stop + remove the service BEFORE RemoveFiles
deletes nssm.exe/setup.ps1. Same CustomActionData marshaling pattern
as WriteConfig. REMOVE="ALL" = full uninstall (not a component-level
repair/modify). -->
<SetProperty Id="RemoveService"
Before="RemoveService" Sequence="execute" Condition="REMOVE=&quot;ALL&quot;"
Value='cmd.exe /c powershell -ExecutionPolicy Bypass -File "[INSTALLDIR]setup.ps1" -Uninstall' />
<CustomAction Id="RemoveService" Directory="INSTALLDIR" ExeCommand="[RemoveService]"
Execute="deferred" Impersonate="no" Return="ignore" />
<InstallExecuteSequence>
<Custom Action="WriteConfig" After="InstallFiles" Condition="NOT Installed" />
<Custom Action="RemoveService" Before="RemoveFiles" Condition="REMOVE=&quot;ALL&quot;" />
</InstallExecuteSequence>
</Package>
</Wix>
-92
View File
@@ -1,92 +0,0 @@
syntax = "proto3";
package keymanager.v1;
option go_package = "github.com/mrhid6/keymanager/server/internal/grpc/pb";
service KeyManager {
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
// Bidirectional stream: agent sends auth once, server pushes commands.
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
}
message RegisterRequest {
string server_id = 1;
string pre_reg_token = 2;
string hostname = 3;
string ip_address = 4;
string os_info = 5;
}
message RegisterResponse {
string agent_token = 1;
}
message SyncRequest {
string server_id = 1;
string agent_token = 2;
string agent_version = 3;
}
message SyncResponse {
repeated string public_keys = 1;
}
message UploadKeyRequest {
string server_id = 1;
string agent_token = 2;
string public_key = 3;
string label = 4;
string private_key = 5;
}
message UploadKeyResponse {
string key_id = 1;
}
// CommandStream messages
message AgentMessage {
string server_id = 1;
string agent_token = 2;
oneof payload {
AgentReady ready = 3;
CommandResult result = 4;
}
}
message AgentReady {}
message CommandResult {
string command_id = 1;
bool success = 2;
string message = 3;
}
message ServerCommand {
string command_id = 1;
oneof command {
GenerateKeyCmd generate_key = 2;
DeleteKeyCmd delete_key = 3;
UpdateAgentCmd update_agent = 4;
}
}
message DeleteKeyCmd {
string label = 1;
}
message UpdateAgentCmd {
string version = 1; // e.g. "1.2.3"
string gitea_base_url = 2; // e.g. "https://gitea.example.com"
}
message GenerateKeyCmd {
string label = 1;
string key_type = 2; // ed25519 | rsa | ecdsa (default: ed25519)
int32 key_size = 3; // bits; used for rsa and ecdsa
string passphrase = 4; // empty = no passphrase
string comment = 5; // embedded in public key
}
+220
View File
@@ -0,0 +1,220 @@
syntax = "proto3";
package vantage.v1;
option go_package = "github.com/mrhid6/vantage/server/internal/grpc/pb";
service Vantage {
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
rpc ReportUpdates(ReportUpdatesRequest) returns (ReportUpdatesResponse);
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
rpc SyncMonitors(SyncMonitorsRequest) returns (SyncMonitorsResponse);
rpc ReportChecks(ReportChecksRequest) returns (ReportChecksResponse);
// Bidirectional stream: agent sends auth once, server pushes commands.
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
}
message RegisterRequest {
string server_id = 1;
string pre_reg_token = 2;
string hostname = 3;
string ip_address = 4;
string os_info = 5;
}
message RegisterResponse {
string agent_token = 1;
}
message SyncRequest {
string server_id = 1;
string agent_token = 2;
string agent_version = 3;
}
message SyncResponse {
repeated string public_keys = 1;
}
message UploadKeyRequest {
string server_id = 1;
string agent_token = 2;
string public_key = 3;
string label = 4;
string private_key = 5;
}
message UploadKeyResponse {
string key_id = 1;
}
// CommandStream messages
message AgentMessage {
string server_id = 1;
string agent_token = 2;
oneof payload {
AgentReady ready = 3;
CommandResult result = 4;
StepResult step_result = 5;
StepOutputChunk step_output = 6;
}
}
message AgentReady {}
message CommandResult {
string command_id = 1;
bool success = 2;
string message = 3;
}
message PackageUpdate {
string name = 1;
string current_version = 2;
string new_version = 3;
}
message ReportUpdatesRequest {
string server_id = 1;
string agent_token = 2;
repeated PackageUpdate updates = 3;
}
message ReportUpdatesResponse {}
message CPUReport {
string model = 1;
int32 cores = 2;
double usage_pct = 3;
double load1 = 4;
}
message MemReport {
uint64 total_bytes = 1;
uint64 used_bytes = 2;
}
message PartitionReport {
string device = 1;
string mountpoint = 2;
string fstype = 3;
uint64 total_bytes = 4;
uint64 used_bytes = 5;
}
message InventoryReport {
string server_id = 1;
string agent_token = 2;
bool include_static = 3;
CPUReport cpu = 4;
MemReport memory = 5;
uint64 swap_total = 6;
uint64 swap_used = 7;
repeated PartitionReport partitions = 8;
string kernel = 9;
}
message InventoryReportResponse {}
message MonitorSpec {
string monitor_id = 1;
string type = 2;
string url = 3;
string host = 4;
int32 port = 5;
string method = 6;
int32 expected_status = 7;
string keyword = 8;
int32 tls_warn_days = 9;
int32 interval_sec = 10;
int32 retries = 11;
bool insecure = 12;
}
message SyncMonitorsRequest {
string server_id = 1;
string agent_token = 2;
}
message SyncMonitorsResponse {
repeated MonitorSpec monitors = 1;
}
message CheckResult {
string monitor_id = 1;
bool up = 2;
int32 latency_ms = 3;
string message = 4;
int64 cert_expiry_unix = 5;
}
message ReportChecksRequest {
string server_id = 1;
string agent_token = 2;
repeated CheckResult results = 3;
}
message ReportChecksResponse {}
message ApplyUpdatesCmd {}
message ServerCommand {
string command_id = 1;
oneof command {
GenerateKeyCmd generate_key = 2;
DeleteKeyCmd delete_key = 3;
UpdateAgentCmd update_agent = 4;
ApplyUpdatesCmd apply_updates = 5;
RunStepCmd run_step = 6;
CleanupWorkspaceCmd cleanup_workspace = 7;
}
}
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
// directory once all steps on that server have finished.
message CleanupWorkspaceCmd {
string workspace_id = 1;
}
message DeleteKeyCmd {
string label = 1;
}
message UpdateAgentCmd {
string version = 1; // e.g. "1.2.3"
string gitea_base_url = 2; // e.g. "https://gitea.example.com"
}
message GenerateKeyCmd {
string label = 1;
string key_type = 2; // ed25519 | rsa | ecdsa (default: ed25519)
int32 key_size = 3; // bits; used for rsa and ecdsa
string passphrase = 4; // empty = no passphrase
string comment = 5; // embedded in public key
}
message RunStepCmd {
string interpreter = 1; // "bash" | "powershell"
string script = 2;
map<string, string> env = 3;
int32 timeout_seconds = 4;
string workspace_id = 5; // per-run working dir the agent creates & uses as cwd
}
message StepResult {
string command_id = 1;
int32 exit_code = 2;
string stdout = 3;
string stderr = 4;
map<string, string> output_env = 5;
}
message StepOutputChunk {
string command_id = 1;
uint64 seq = 2;
bytes data = 3;
bool eof = 4;
}
+3 -3
View File
@@ -11,14 +11,14 @@ RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.Version=${VERSION}" -o /keymanager-server ./cmd
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
# Runtime stage
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /keymanager-server /keymanager-server
COPY --from=builder /vantage-server /vantage-server
EXPOSE 8080 9090
ENTRYPOINT ["/keymanager-server"]
ENTRYPOINT ["/vantage-server"]
+30 -8
View File
@@ -7,22 +7,39 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/keymanager/server/internal/api"
"github.com/mrhid6/keymanager/server/internal/auth"
"github.com/mrhid6/keymanager/server/internal/db"
grpcserver "github.com/mrhid6/keymanager/server/internal/grpc"
"github.com/mrhid6/keymanager/server/internal/services"
"github.com/mrhid6/vantage/server/internal/api"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/db"
grpcserver "github.com/mrhid6/vantage/server/internal/grpc"
"github.com/mrhid6/vantage/server/internal/monitorsched"
"github.com/mrhid6/vantage/server/internal/services"
)
func main() {
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
dbName := getEnv("MONGO_DB", "keymanager")
dbName := getEnv("MONGO_DB", "vantage")
if err := db.Connect(mongoURI, dbName); err != nil {
log.Fatalf("failed to connect to MongoDB: %v", err)
}
log.Println("connected to MongoDB")
if err := services.EnsureSecretIndexes(); err != nil {
log.Printf("warning: failed to ensure secret indexes: %v", err)
}
if err := services.EnsureWorkflowIndexes(); err != nil {
log.Printf("warning: failed to ensure workflow indexes: %v", err)
}
if created, updated, err := services.SeedDefaultSteps(); err != nil {
log.Printf("warning: failed to seed default steps: %v", err)
} else {
log.Printf("default steps seeded: %d created, %d updated", created, updated)
}
services.StartLogSweeper()
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
if err := auth.InitRedis(redisAddr); err != nil {
log.Fatalf("failed to connect to Redis: %v", err)
@@ -38,7 +55,7 @@ func main() {
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
for range ticker.C {
if err := services.MarkOfflineServers(5 * time.Minute); err != nil {
if err := services.MarkOfflineServers(); err != nil {
log.Printf("mark offline error: %v", err)
}
}
@@ -51,8 +68,13 @@ func main() {
}
}()
// Start the server-side monitor scheduler.
monitorsched.Start(context.Background())
// Start REST server
r := gin.Default()
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
r.Use(corsMiddleware())
api.RegisterRoutes(r)
+5 -1
View File
@@ -1,4 +1,4 @@
module github.com/mrhid6/keymanager/server
module github.com/mrhid6/vantage/server
go 1.26
@@ -7,6 +7,7 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.20.1
github.com/wwt/guac v1.3.2
go.mongodb.org/mongo-driver/v2 v2.2.2
golang.org/x/oauth2 v0.36.0
google.golang.org/grpc v1.64.0
@@ -26,14 +27,17 @@ require (
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/gorilla/websocket v1.4.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sirupsen/logrus v1.4.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
+12
View File
@@ -40,8 +40,11 @@ github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
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=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
@@ -50,6 +53,8 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
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/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=
@@ -65,10 +70,14 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
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/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=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -81,6 +90,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
@@ -116,6 +127,7 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
"go.mongodb.org/mongo-driver/v2/bson"
)
func registerChannelRoutes(g *gin.RouterGroup) {
g.GET("/channels", listChannels)
g.POST("/channels", createChannel)
g.PUT("/channels/:id", updateChannel)
g.DELETE("/channels/:id", deleteChannel)
g.POST("/channels/:id/test", testChannel)
}
func listChannels(c *gin.Context) {
channels, err := services.ListChannels()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, channels)
}
func createChannel(c *gin.Context) {
var ch models.NotificationChannel
if err := c.ShouldBindJSON(&ch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if ch.Name == "" || ch.Type == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
return
}
created, err := services.CreateChannel(&ch)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, created)
}
func updateChannel(c *gin.Context) {
var body struct {
Name *string `json:"name"`
Type *string `json:"type"`
Config *map[string]string `json:"config"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
upd := bson.M{}
if body.Name != nil {
upd["name"] = *body.Name
}
if body.Type != nil {
upd["type"] = *body.Type
}
if body.Config != nil {
upd["config"] = *body.Config
}
if body.Enabled != nil {
upd["enabled"] = *body.Enabled
}
if len(upd) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := services.UpdateChannel(c.Param("id"), upd); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func deleteChannel(c *gin.Context) {
if err := services.DeleteChannel(c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func testChannel(c *gin.Context) {
if err := services.TestChannel(c.Param("id")); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "sent"})
}
+177
View File
@@ -0,0 +1,177 @@
package api
import (
"net"
"net/http"
"os"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/services"
"github.com/wwt/guac"
)
// POST /api/console/connect
// Body: { server_id, protocol, key_id?, rdp_username?, rdp_password? }
// Returns: { session_id, token, ws_path }
func consoleConnect(c *gin.Context) {
var body struct {
ServerID string `json:"server_id" binding:"required"`
Protocol string `json:"protocol" binding:"required"`
KeyID string `json:"key_id"`
RDPUsername string `json:"rdp_username"`
RDPPassword string `json:"rdp_password"`
SSHUsername string `json:"ssh_username"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
srv, err := services.GetServer(body.ServerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
sess, err := services.CreateConsoleSession(body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
token, err := services.SignSessionToken(sess.SessionID, time.Minute)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if (body.Protocol == "rdp" || body.Protocol == "vnc") && (body.RDPUsername != "" || body.RDPPassword != "") {
if err := services.StashConsoleRDPCreds(sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if body.Protocol == "ssh" {
if err := services.SetConsoleSSHUser(sess.SessionID, body.SSHUsername); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
services.LogEvent("console.opened", actorFromCtx(c), srv.ServerID, "",
"console session opened ("+body.Protocol+")")
c.JSON(http.StatusOK, gin.H{
"session_id": sess.SessionID,
"token": token,
"ws_path": "/api/console/tunnel",
})
}
// queryIntDefault reads a positive integer query param, falling back to def
// when absent, unparseable, or non-positive.
func queryIntDefault(r *http.Request, key string, def int) int {
v, err := strconv.Atoi(r.URL.Query().Get(key))
if err != nil || v <= 0 {
return def
}
return v
}
// GET /api/console/tunnel?token=... (WebSocket upgrade)
func consoleTunnel(c *gin.Context) {
token := c.Query("token")
sessionID, err := services.VerifySessionToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
sess, err := services.GetConsoleSession(sessionID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
// User-bound: the caller (authenticated via session cookie) must be the same
// user who opened the session. Blocks a leaked token being used by someone else.
if actor := actorFromCtx(c); actor != sess.User {
c.JSON(http.StatusForbidden, gin.H{"error": "session belongs to another user"})
return
}
// Single-use: atomically spend the token so a replay within its TTL is rejected.
if err := services.ConsumeSessionToken(sessionID); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"})
return
}
srv, err := services.GetServer(sess.ServerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
// Decrypt private key + passphrase in-memory only (ssh).
var privKey, passphrase string
if sess.Protocol == "ssh" && sess.KeyID != "" {
privKey, err = services.GetPrivateKey(sess.KeyID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"})
return
}
passphrase, _ = services.GetPassphrase(sess.KeyID)
}
var rdpUser, rdpPass string
if sess.Protocol == "rdp" || sess.Protocol == "vnc" {
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load credentials"})
return
}
}
gp, err := services.BuildGuacParams(srv, sess.Protocol, sess.SSHUsername, privKey, passphrase, rdpUser, rdpPass)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
guacdAddr := os.Getenv("GUACD_ADDR")
if guacdAddr == "" {
guacdAddr = "guacd:4822"
}
// Build a guac tunnel config from our params.
connect := func(r *http.Request) (guac.Tunnel, error) {
config := guac.NewGuacamoleConfiguration()
config.Protocol = gp.Protocol
for k, v := range gp.Params {
config.Parameters[k] = v
}
config.OptimalScreenWidth = queryIntDefault(r, "width", 1024)
config.OptimalScreenHeight = queryIntDefault(r, "height", 768)
config.OptimalResolution = queryIntDefault(r, "dpi", 96)
addr, err := net.ResolveTCPAddr("tcp", guacdAddr)
if err != nil {
return nil, err
}
conn, err := net.DialTCP("tcp", nil, addr)
if err != nil {
return nil, err
}
stream := guac.NewStream(conn, guac.SocketTimeout)
if err := stream.Handshake(config); err != nil {
return nil, err
}
return guac.NewSimpleTunnel(stream), nil
}
wsServer := guac.NewWebsocketServer(connect)
wsServer.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) {
_ = services.EndConsoleSession(sessionID)
}
wsServer.ServeHTTP(c.Writer, c.Request)
}
+159 -37
View File
@@ -4,16 +4,33 @@ import (
"fmt"
"net/http"
"os"
"strconv"
"github.com/gin-gonic/gin"
"github.com/mrhid6/keymanager/server/internal/auth"
"github.com/mrhid6/keymanager/server/internal/models"
"github.com/mrhid6/keymanager/server/internal/services"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
func actorFromCtx(c *gin.Context) string {
if sess := auth.GetSessionFromContext(c); sess != nil && sess.Email != "" {
return sess.Email
}
return "admin"
}
func RegisterRoutes(r *gin.Engine) {
r.GET("/install", handleInstallScript)
r.GET("/install.ps1", handleInstallScriptWindows)
r.GET("/update", handleUpdateScript)
r.GET("/update.ps1", handleUpdateScriptWindows)
// ESO read endpoint — bearer-token auth, not session auth, so Kubernetes
// External Secrets Operator can call it. Lives under /api (so the reverse
// proxy routes it to the backend) but on a distinct subpath to avoid
// colliding with the session-authed GET /api/secrets/:group. Returns a
// group as flat JSON.
r.GET("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup)
// Auth endpoints (no session required)
r.GET("/auth/login", auth.HandleLogin)
@@ -33,9 +50,24 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.DELETE("/servers/:id", deleteServer)
apiGroup.POST("/servers/:id/generate-key", generateKey)
apiGroup.POST("/servers/:id/update-agent", updateAgent)
apiGroup.POST("/servers/:id/apply-updates", applyUpdates)
apiGroup.GET("/agent/latest-version", getLatestAgentVersion)
apiGroup.GET("/audit", listAuditEvents)
apiGroup.GET("/settings", getSettings)
apiGroup.PUT("/settings", saveSettings)
apiGroup.POST("/settings/secrets-token", rotateSecretsToken)
apiGroup.GET("/secrets", listSecretGroups)
apiGroup.POST("/secrets", createSecretGroup)
apiGroup.GET("/secrets/:group", getSecretGroup)
apiGroup.PUT("/secrets/:group", putSecretGroup)
apiGroup.POST("/secrets/:group/reveal", revealSecret)
apiGroup.DELETE("/secrets/:group", deleteSecretGroup)
apiGroup.DELETE("/secrets/:group/:key", deleteSecretKey)
apiGroup.GET("/keys", listKeys)
apiGroup.POST("/keys", createKey)
apiGroup.GET("/keys/:id", getKey)
@@ -43,6 +75,13 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.DELETE("/keys/:id", deleteKey)
apiGroup.POST("/keys/:id/assign", assignKey)
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
apiGroup.POST("/console/connect", consoleConnect)
apiGroup.GET("/console/tunnel", consoleTunnel)
registerWorkflowRoutes(apiGroup)
registerMonitorRoutes(apiGroup)
registerChannelRoutes(apiGroup)
}
}
@@ -74,6 +113,7 @@ func newServer(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
@@ -81,7 +121,7 @@ func newServer(c *gin.Context) {
}
host := os.Getenv("PUBLIC_HOST")
if host == "" {
host = "https://keymanager.example.com"
host = "https://vantage.example.com"
}
installCmd := fmt.Sprintf(
@@ -89,10 +129,16 @@ func newServer(c *gin.Context) {
host, s.ServerID, token,
)
installCmdPS := fmt.Sprintf(
`irm "%s/install.ps1?server_id=%s&token=%s" | iex`,
host, s.ServerID, token,
)
c.JSON(http.StatusOK, gin.H{
"server_id": s.ServerID,
"pre_reg_token": token,
"install_command": installCmd,
"server_id": s.ServerID,
"pre_reg_token": token,
"install_command": installCmd,
"install_command_ps": installCmdPS,
})
}
@@ -119,10 +165,16 @@ func getServer(c *gin.Context) {
func deleteServer(c *gin.Context) {
id := c.Param("id")
s, _ := services.GetServer(id)
if err := services.DeleteServer(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
hostname := id
if s != nil {
hostname = s.Hostname
}
services.LogEvent("server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -159,6 +211,7 @@ func generateKey(c *gin.Context) {
return
}
services.LogEvent("key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
c.JSON(http.StatusAccepted, gin.H{
"message": "key generation command sent to agent",
"command_id": cmdID,
@@ -180,17 +233,19 @@ func createKey(c *gin.Context) {
Label string `json:"label" binding:"required"`
PublicKey string `json:"public_key" binding:"required"`
PrivateKey string `json:"private_key"`
Passphrase string `json:"passphrase"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey)
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label))
c.JSON(http.StatusCreated, key)
}
@@ -226,10 +281,16 @@ func getKey(c *gin.Context) {
func deleteKey(c *gin.Context) {
id := c.Param("id")
k, _ := services.GetKey(id)
if err := services.DeleteKey(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
label := id
if k != nil {
label = k.Label
}
services.LogEvent("key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -248,6 +309,7 @@ func assignKey(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
c.JSON(http.StatusCreated, a)
}
@@ -259,6 +321,7 @@ func revokeAssignment(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
c.JSON(http.StatusOK, gin.H{"revoked": true})
}
@@ -284,12 +347,29 @@ func updateAgent(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent("agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
c.JSON(http.StatusAccepted, gin.H{
"message": "update command sent to agent",
"version": version,
})
}
func applyUpdates(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := services.DispatchApplyUpdates(s.ServerID); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent("updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
c.JSON(http.StatusAccepted, gin.H{"message": "apply updates command sent to agent"})
}
func handleUpdateScript(c *gin.Context) {
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
@@ -309,7 +389,7 @@ case "$ARCH" in
esac
# Get latest agent release tag
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/keymanager/releases?limit=10" \
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/vantage/releases?limit=10" \
| grep -o '"tag_name":"agent/v[^"]*"' | head -1 | sed 's/"tag_name":"//;s/"//')
if [ -z "$LATEST" ]; then
@@ -319,33 +399,75 @@ fi
VERSION="${LATEST#agent/}"
LATEST_ENCODED="${LATEST/\//%%2F}"
BINARY_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST_ENCODED}/keymanager-agent-linux-${ARCH}"
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST_ENCODED}/checksums.txt"
BINARY_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}"
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/checksums.txt"
echo "Updating keymanager-agent to ${VERSION} (${ARCH})..."
echo "Updating vantage-agent to ${VERSION} (${ARCH})..."
curl -fsSL -o /tmp/keymanager-agent "${BINARY_URL}"
curl -fsSL -o /tmp/vantage-agent "${BINARY_URL}"
curl -fsSL -o /tmp/checksums.txt "${CHECKSUM_URL}"
cd /tmp
EXPECTED=$(grep "keymanager-agent-linux-${ARCH}" checksums.txt | awk '{print $1}')
ACTUAL=$(sha256sum keymanager-agent | awk '{print $1}')
EXPECTED=$(grep "vantage-agent-linux-${ARCH}" checksums.txt | awk '{print $1}')
ACTUAL=$(sha256sum vantage-agent | awk '{print $1}')
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "Checksum mismatch!" >&2
exit 1
fi
systemctl stop keymanager-agent || true
install -m 0755 /tmp/keymanager-agent /usr/local/bin/keymanager-agent
systemctl start keymanager-agent
systemctl stop vantage-agent || true
install -m 0755 /tmp/vantage-agent /usr/local/bin/vantage-agent
systemctl start vantage-agent
echo "keymanager-agent updated to ${VERSION} and restarted."
echo "vantage-agent updated to ${VERSION} and restarted."
`, giteaHost)
c.Header("Content-Type", "text/x-shellscript")
c.String(http.StatusOK, script)
}
func listAuditEvents(c *gin.Context) {
limit := int64(100)
if l := c.Query("limit"); l != "" {
if n, err := strconv.ParseInt(l, 10, 64); err == nil && n > 0 {
limit = n
}
}
events, err := services.ListAuditEvents(limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, events)
}
func getSettings(c *gin.Context) {
s, err := services.GetSettings()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, s)
}
func saveSettings(c *gin.Context) {
var body struct {
Alerts models.AlertSettings `json:"alerts"`
Email models.EmailSettings `json:"email"`
WorkflowLogRetentionDays *int `json:"workflow_log_retention_days"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.SaveSettings(body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("settings.updated", actorFromCtx(c), "", "", "alert settings updated")
c.JSON(http.StatusOK, gin.H{"saved": true})
}
func handleInstallScript(c *gin.Context) {
serverID := c.Query("server_id")
token := c.Query("token")
@@ -356,7 +478,7 @@ func handleInstallScript(c *gin.Context) {
}
publicHost := os.Getenv("PUBLIC_HOST")
if publicHost == "" {
publicHost = "keymanager.example.com"
publicHost = "vantage.example.com"
}
grpcHost := os.Getenv("GRPC_HOST")
if grpcHost == "" {
@@ -384,7 +506,7 @@ case "$ARCH" in
esac
# Get latest agent release tag
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/keymanager/releases?limit=10" \
LATEST=$(curl -fsSL "https://${GITEA_HOST}/api/v1/repos/mrhid6/vantage/releases?limit=10" \
| grep -o '"tag_name":"agent/v[^"]*"' | head -1 | sed 's/"tag_name":"//;s/"//')
if [ -z "$LATEST" ]; then
@@ -394,28 +516,28 @@ fi
VERSION="${LATEST#agent/}"
LATEST_ENCODED="${LATEST/\//%%2F}"
BINARY_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST_ENCODED}/keymanager-agent-linux-${ARCH}"
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/keymanager/releases/download/${LATEST_ENCODED}/checksums.txt"
BINARY_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/vantage-agent-linux-${ARCH}"
CHECKSUM_URL="https://${GITEA_HOST}/mrhid6/vantage/releases/download/${LATEST_ENCODED}/checksums.txt"
echo "Installing keymanager-agent ${VERSION} (${ARCH})..."
echo "Installing vantage-agent ${VERSION} (${ARCH})..."
curl -fsSL -o /tmp/keymanager-agent "${BINARY_URL}"
curl -fsSL -o /tmp/vantage-agent "${BINARY_URL}"
curl -fsSL -o /tmp/checksums.txt "${CHECKSUM_URL}"
cd /tmp
EXPECTED=$(grep "keymanager-agent-linux-${ARCH}" checksums.txt | awk '{print $1}')
ACTUAL=$(sha256sum keymanager-agent | awk '{print $1}')
EXPECTED=$(grep "vantage-agent-linux-${ARCH}" checksums.txt | awk '{print $1}')
ACTUAL=$(sha256sum vantage-agent | awk '{print $1}')
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "Checksum mismatch!" >&2
exit 1
fi
install -m 0755 /tmp/keymanager-agent /usr/local/bin/keymanager-agent
install -m 0755 /tmp/vantage-agent /usr/local/bin/vantage-agent
mkdir -p /etc/keymanager
chmod 0700 /etc/keymanager
mkdir -p /etc/vantage
chmod 0700 /etc/vantage
cat > /etc/keymanager/config.yaml <<EOF
cat > /etc/vantage/config.yaml <<EOF
server_url: "${GRPC_HOST}"
server_id: "${SERVER_ID}"
pre_reg_token: "${TOKEN}"
@@ -423,15 +545,15 @@ agent_token: ""
poll_interval: 30s
tls: true
EOF
chmod 0600 /etc/keymanager/config.yaml
chmod 0600 /etc/vantage/config.yaml
cat > /etc/systemd/system/keymanager-agent.service <<EOF
cat > /etc/systemd/system/vantage-agent.service <<EOF
[Unit]
Description=KeyManager Agent
Description=Vantage Agent
After=network.target
[Service]
ExecStart=/usr/local/bin/keymanager-agent
ExecStart=/usr/local/bin/vantage-agent
Restart=always
RestartSec=10
User=root
@@ -441,9 +563,9 @@ WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now keymanager-agent
systemctl enable --now vantage-agent
echo "keymanager-agent installed and started."
echo "vantage-agent installed and started."
`, serverID, token, giteaHost, publicHost, grpcHost)
c.Header("Content-Type", "text/x-shellscript")
+93
View File
@@ -0,0 +1,93 @@
package api
import (
"fmt"
"net/http"
"os"
"github.com/gin-gonic/gin"
)
func handleInstallScriptWindows(c *gin.Context) {
serverID := c.Query("server_id")
token := c.Query("token")
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
grpcHost := os.Getenv("GRPC_HOST")
if grpcHost == "" {
grpcHost = os.Getenv("PUBLIC_HOST")
}
if grpcHost == "" {
grpcHost = "vantage.example.com"
}
script := fmt.Sprintf(
"#Requires -RunAsAdministrator\n"+
"$ErrorActionPreference = \"Stop\"\n"+
"\n"+
"$ServerId = \"%s\"\n"+
"$Token = \"%s\"\n"+
"$GiteaHost = \"%s\"\n"+
"$ServerUrl = \"%s\" -replace '^https?://',''\n"+
"\n"+
"$rel = Invoke-RestMethod -Uri \"https://$GiteaHost/api/v1/repos/mrhid6/vantage/releases?limit=10\"\n"+
"$tag = ($rel | Where-Object { $_.tag_name -like 'agent/v*' } | Select-Object -First 1).tag_name\n"+
"if (-not $tag) { throw \"Could not determine latest agent version\" }\n"+
"$enc = $tag -replace '/','%%2F'\n"+
"$base = \"https://$GiteaHost/mrhid6/vantage/releases/download/$enc\"\n"+
"\n"+
"$tmp = Join-Path $env:TEMP \"vantage-agent.msi\"\n"+
"Invoke-WebRequest -Uri \"$base/vantage-agent.msi\" -OutFile $tmp\n"+
"Invoke-WebRequest -Uri \"$base/checksums-msi.txt\" -OutFile \"$env:TEMP\\checksums-msi.txt\"\n"+
"\n"+
"$expected = (Get-Content \"$env:TEMP\\checksums-msi.txt\" | Select-String 'vantage-agent.msi').ToString().Split()[0]\n"+
"$actual = (Get-FileHash $tmp -Algorithm SHA256).Hash.ToLower()\n"+
"if ($expected -ne $actual) { throw \"Checksum mismatch\" }\n"+
"\n"+
"Start-Process msiexec.exe -Wait -ArgumentList \"/i `\"$tmp`\" /qn SERVERID=$ServerId TOKEN=$Token SERVERURL=$ServerUrl\"\n"+
"Write-Host \"Vantage agent installed.\"\n",
serverID, token, giteaHost, grpcHost)
c.Header("Content-Type", "text/plain; charset=utf-8")
c.String(http.StatusOK, script)
}
// handleUpdateScriptWindows serves a PowerShell one-liner that upgrades an
// already-installed Windows agent. No server_id/token needed: the MSI is a
// MajorUpgrade and setup.ps1 preserves the existing config on upgrade.
func handleUpdateScriptWindows(c *gin.Context) {
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
script := fmt.Sprintf(
"#Requires -RunAsAdministrator\n"+
"$ErrorActionPreference = \"Stop\"\n"+
"\n"+
"$GiteaHost = \"%s\"\n"+
"\n"+
"$rel = Invoke-RestMethod -Uri \"https://$GiteaHost/api/v1/repos/mrhid6/vantage/releases?limit=10\"\n"+
"$tag = ($rel | Where-Object { $_.tag_name -like 'agent/v*' } | Select-Object -First 1).tag_name\n"+
"if (-not $tag) { throw \"Could not determine latest agent version\" }\n"+
"$enc = $tag -replace '/','%%2F'\n"+
"$base = \"https://$GiteaHost/mrhid6/vantage/releases/download/$enc\"\n"+
"\n"+
"$tmp = Join-Path $env:TEMP \"vantage-agent.msi\"\n"+
"Invoke-WebRequest -Uri \"$base/vantage-agent.msi\" -OutFile $tmp\n"+
"Invoke-WebRequest -Uri \"$base/checksums-msi.txt\" -OutFile \"$env:TEMP\\checksums-msi.txt\"\n"+
"\n"+
"$expected = (Get-Content \"$env:TEMP\\checksums-msi.txt\" | Select-String 'vantage-agent.msi').ToString().Split()[0]\n"+
"$actual = (Get-FileHash $tmp -Algorithm SHA256).Hash.ToLower()\n"+
"if ($expected -ne $actual) { throw \"Checksum mismatch\" }\n"+
"\n"+
"Start-Process msiexec.exe -Wait -ArgumentList \"/i `\"$tmp`\" /qn /norestart\"\n"+
"Write-Host \"Vantage agent updated to $tag.\"\n",
giteaHost)
c.Header("Content-Type", "text/plain; charset=utf-8")
c.String(http.StatusOK, script)
}
+139
View File
@@ -0,0 +1,139 @@
package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
"go.mongodb.org/mongo-driver/v2/bson"
)
func registerMonitorRoutes(g *gin.RouterGroup) {
g.GET("/monitors", listMonitors)
g.POST("/monitors", createMonitor)
g.GET("/monitors/:id", getMonitor)
g.PUT("/monitors/:id", updateMonitor)
g.DELETE("/monitors/:id", deleteMonitor)
g.GET("/monitors/:id/incidents", getMonitorIncidents)
g.GET("/monitors/:id/uptime", getMonitorUptime)
}
func listMonitors(c *gin.Context) {
monitors, err := services.ListMonitors()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, monitors)
}
func createMonitor(c *gin.Context) {
var m models.Monitor
if err := c.ShouldBindJSON(&m); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if m.Name == "" || m.Type == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
return
}
created, err := services.CreateMonitor(&m)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, created)
}
func getMonitor(c *gin.Context) {
m, err := services.GetMonitor(c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
return
}
c.JSON(http.StatusOK, m)
}
func updateMonitor(c *gin.Context) {
var body struct {
Name *string `json:"name"`
Type *string `json:"type"`
Target *models.MonitorTarget `json:"target"`
IntervalSec *int `json:"interval_sec"`
Runner *string `json:"runner"`
Retries *int `json:"retries"`
Enabled *bool `json:"enabled"`
ChannelIDs *[]string `json:"channel_ids"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
upd := bson.M{}
if body.Name != nil {
upd["name"] = *body.Name
}
if body.Type != nil {
upd["type"] = *body.Type
}
if body.Target != nil {
upd["target"] = *body.Target
}
if body.IntervalSec != nil {
upd["interval_sec"] = *body.IntervalSec
}
if body.Runner != nil {
upd["runner"] = *body.Runner
}
if body.Retries != nil {
upd["retries"] = *body.Retries
}
if body.Enabled != nil {
upd["enabled"] = *body.Enabled
}
if body.ChannelIDs != nil {
upd["channel_ids"] = *body.ChannelIDs
}
if len(upd) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := services.UpdateMonitor(c.Param("id"), upd); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func deleteMonitor(c *gin.Context) {
if err := services.DeleteMonitor(c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func getMonitorIncidents(c *gin.Context) {
incidents, err := services.ListIncidents(c.Param("id"), 50)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, incidents)
}
func getMonitorUptime(c *gin.Context) {
since := time.Now().Add(-30 * 24 * time.Hour)
rollups, err := services.UptimeRollups(c.Param("id"), since)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rollups)
}
+188
View File
@@ -0,0 +1,188 @@
package api
import (
"fmt"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/services"
)
// groupNamePattern restricts group and key names to characters that are safe
// in URLs and Kubernetes/env contexts.
var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
func validName(s string) bool {
return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s)
}
// secretsReadAuth validates the ESO bearer token on the public read endpoint.
func secretsReadAuth() gin.HandlerFunc {
return func(c *gin.Context) {
const prefix = "Bearer "
auth := c.GetHeader("Authorization")
if len(auth) <= len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return
}
if !services.VerifySecretsReadToken(auth[len(prefix):]) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Next()
}
}
// esoGetGroup handles GET /secrets/:group for the External Secrets Operator.
// Returns a flat JSON object { "KEY": "value", ... }; 404 if the group is empty
// (ESO treats 404 as "deleted").
func esoGetGroup(c *gin.Context) {
group := c.Param("group")
values, err := services.GetSecretGroupDecrypted(group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
return
}
if len(values) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
c.JSON(http.StatusOK, values)
}
func listSecretGroups(c *gin.Context) {
groups, err := services.ListSecretGroups()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, groups)
}
// createSecretGroup handles POST /api/secrets. A group is implicit, so it must
// be created with at least one key/value pair.
func createSecretGroup(c *gin.Context) {
var body struct {
Group string `json:"group" binding:"required"`
Values map[string]string `json:"values"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !validName(body.Group) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group name"})
return
}
if len(body.Values) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "a group must be created with at least one key"})
return
}
for k := range body.Values {
if !validName(k) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid key name: %s", k)})
return
}
}
if err := services.UpsertSecrets(body.Group, body.Values); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
c.JSON(http.StatusCreated, gin.H{"group": body.Group})
}
func getSecretGroup(c *gin.Context) {
group := c.Param("group")
secrets, err := services.GetSecretGroup(group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(secrets) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "group not found"})
return
}
c.JSON(http.StatusOK, gin.H{"group": group, "secrets": secrets})
}
// putSecretGroup upserts one or more keys into an existing (or new) group.
func putSecretGroup(c *gin.Context) {
group := c.Param("group")
if !validName(group) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid group name"})
return
}
var values map[string]string
if err := c.ShouldBindJSON(&values); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
return
}
if len(values) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "body must contain at least one key"})
return
}
for k := range values {
if !validName(k) {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid key name: %s", k)})
return
}
}
if err := services.UpsertSecrets(group, values); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
c.JSON(http.StatusOK, gin.H{"saved": true})
}
func revealSecret(c *gin.Context) {
group := c.Param("group")
var body struct {
Key string `json:"key" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
value, err := services.RevealSecret(group, body.Key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
c.JSON(http.StatusOK, gin.H{"value": value})
}
func deleteSecretKey(c *gin.Context) {
group := c.Param("group")
key := c.Param("key")
if err := services.DeleteSecret(group, key); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func deleteSecretGroup(c *gin.Context) {
group := c.Param("group")
if err := services.DeleteSecretGroup(group); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func rotateSecretsToken(c *gin.Context) {
token, err := services.RotateSecretsReadToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
c.JSON(http.StatusOK, gin.H{"token": token})
}
+362
View File
@@ -0,0 +1,362 @@
package api
import (
"fmt"
"io"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
func registerWorkflowRoutes(g *gin.RouterGroup) {
g.GET("/steps", listSteps)
g.POST("/steps", createStep)
g.PUT("/steps/:id", updateStep)
g.DELETE("/steps/:id", deleteStep)
g.GET("/steps/:id/export", exportStep)
g.POST("/steps/import", importStep)
g.POST("/steps/seed-defaults", seedDefaults)
g.GET("/steps/usage", stepUsage)
g.POST("/steps/parse", parseStep)
g.GET("/workflows", listWorkflows)
g.POST("/workflows", createWorkflow)
g.GET("/workflows/:id", getWorkflow)
g.PUT("/workflows/:id", updateWorkflow)
g.DELETE("/workflows/:id", deleteWorkflow)
g.POST("/workflows/:id/run", runWorkflow)
g.GET("/workflows/:id/runs", listWorkflowRuns)
g.GET("/runs/:runId", getRun)
g.POST("/runs/:runId/cancel", cancelRun)
g.GET("/runs/:runId/servers/:serverId/logs", getServerRunLog)
g.GET("/runs/:runId/servers/:serverId/logs/stream", streamServerRunLog)
}
var uuidLike = regexp.MustCompile(`^[a-zA-Z0-9-]{1,64}$`)
func getServerRunLog(c *gin.Context) {
runID, serverID := c.Param("runId"), c.Param("serverId")
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
path := services.ServerRunLogPath(runID, serverID)
b, err := os.ReadFile(path)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no logs"})
return
}
c.Data(http.StatusOK, "text/plain; charset=utf-8", b)
}
func streamServerRunLog(c *gin.Context) {
runID, serverID := c.Param("runId"), c.Param("serverId")
if !uuidLike.MatchString(runID) || !uuidLike.MatchString(serverID) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
path := services.ServerRunLogPath(runID, serverID)
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("X-Accel-Buffering", "no")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "stream unsupported"})
return
}
var offset int64
sendNew := func() {
f, err := os.Open(path)
if err != nil {
return // file may not exist yet; keep waiting
}
defer f.Close()
if _, err := f.Seek(offset, 0); err != nil {
return
}
buf := make([]byte, 32*1024)
for {
n, _ := f.Read(buf)
if n <= 0 {
break
}
offset += int64(n)
// SSE data frame; split on newlines to keep frames well-formed.
for _, line := range splitSSE(buf[:n]) {
_, _ = c.Writer.WriteString("data: " + line + "\n")
}
_, _ = c.Writer.WriteString("\n")
flusher.Flush()
}
}
ctx := c.Request.Context()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
sendNew()
if serverRunTerminal(runID, serverID) {
sendNew() // final drain
_, _ = c.Writer.WriteString("event: done\ndata: end\n\n")
flusher.Flush()
return
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// serverRunTerminal reports whether the given server-run has reached a terminal status.
func serverRunTerminal(runID, serverID string) bool {
r, err := services.GetRun(runID)
if err != nil {
return true
}
for _, sr := range r.ServerRuns {
if sr.ServerID == serverID {
switch sr.Status {
case "success", "failed", "skipped", "cancelled":
return true
}
return false
}
}
return true
}
// splitSSE turns a raw byte slice into SSE-safe payload lines (newlines become
// separate data lines; carriage returns stripped).
func splitSSE(b []byte) []string {
s := strings.ReplaceAll(string(b), "\r", "")
return strings.Split(s, "\n")
}
func listSteps(c *gin.Context) {
steps, err := services.ListSteps()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, steps)
}
func stepUsage(c *gin.Context) {
counts, err := services.StepUsageCounts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, counts)
}
func createStep(c *gin.Context) {
var s models.WorkflowStep
if err := c.ShouldBindJSON(&s); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := services.CreateStep(s)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name))
c.JSON(http.StatusCreated, out)
}
func updateStep(c *gin.Context) {
var s models.WorkflowStep
if err := c.ShouldBindJSON(&s); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.UpdateStep(c.Param("id"), s); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated")
c.JSON(http.StatusOK, gin.H{"updated": true})
}
func deleteStep(c *gin.Context) {
if err := services.DeleteStep(c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted")
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func exportStep(c *gin.Context) {
b, err := services.ExportStep(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=step-%s.json", c.Param("id")))
c.Data(http.StatusOK, "application/json", b)
}
func seedDefaults(c *gin.Context) {
created, updated, err := services.SeedDefaultSteps()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated))
c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated})
}
const maxStepBodyBytes = 1 << 20 // 1 MiB
func importStep(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := services.ImportStepToLibrary(body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name))
c.JSON(http.StatusCreated, out)
}
// parseStep validates a step doc and returns the normalized step WITHOUT
// persisting — used by the editor to insert an imported ad-hoc (inline) step.
func parseStep(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
s, err := services.ParseStepDoc(body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, s)
}
func listWorkflows(c *gin.Context) {
wfs, err := services.ListWorkflows()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, wfs)
}
func createWorkflow(c *gin.Context) {
var w models.Workflow
if err := c.ShouldBindJSON(&w); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := services.CreateWorkflow(w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name))
c.JSON(http.StatusCreated, out)
}
func getWorkflow(c *gin.Context) {
w, err := services.GetWorkflow(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, w)
}
func updateWorkflow(c *gin.Context) {
var w models.Workflow
if err := c.ShouldBindJSON(&w); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.UpdateWorkflow(c.Param("id"), w); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
updated, err := services.GetWorkflow(c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, updated)
}
func deleteWorkflow(c *gin.Context) {
if err := services.DeleteWorkflow(c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted")
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func runWorkflow(c *gin.Context) {
runID, err := services.TriggerWorkflow(c.Param("id"), actorFromCtx(c))
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID))
c.JSON(http.StatusAccepted, gin.H{"run_id": runID})
}
func listWorkflowRuns(c *gin.Context) {
limit := int64(50)
if l := c.Query("limit"); l != "" {
if n, err := strconv.ParseInt(l, 10, 64); err == nil && n > 0 {
limit = n
}
}
runs, err := services.ListRuns(c.Param("id"), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, runs)
}
func getRun(c *gin.Context) {
r, err := services.GetRun(c.Param("runId"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, r)
}
func cancelRun(c *gin.Context) {
if err := services.CancelRun(c.Param("runId")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
c.JSON(http.StatusOK, gin.H{"cancelled": true})
}
+226
View File
@@ -0,0 +1,226 @@
// Package checker runs service checks (http/tcp/icmp/tls) and returns a uniform
// Result. It has no dependency on models or pb so it can be duplicated verbatim
// into the agent module (agent-run monitors) — callers map their own monitor
// representation onto Spec.
package checker
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"time"
)
// Check types (mirror models.Monitor* constants).
const (
TypeHTTP = "http"
TypeTCP = "tcp"
TypeICMP = "icmp"
TypeTLS = "tls"
)
// Spec is a self-contained description of a single check.
type Spec struct {
Type string
URL string
Host string
Port int
Method string
ExpectedStatus int
Keyword string
TLSWarnDays int
Insecure bool // skip TLS certificate verification (HTTP checks)
TimeoutSec int
}
// Result is the uniform outcome of running a check.
type Result struct {
Up bool
LatencyMs int
Message string
CertExpiry *time.Time
}
func (s Spec) timeout() time.Duration {
t := s.TimeoutSec
if t <= 0 || t > 10 {
t = 10
}
return time.Duration(t) * time.Second
}
// Run executes the check described by s.
func Run(ctx context.Context, s Spec) Result {
switch s.Type {
case TypeHTTP:
return runHTTP(ctx, s)
case TypeTCP:
return runTCP(ctx, s)
case TypeICMP:
return runICMP(ctx, s)
case TypeTLS:
return runTLS(ctx, s)
default:
return Result{Message: "unknown check type: " + s.Type}
}
}
func runHTTP(ctx context.Context, s Spec) Result {
method := s.Method
if method == "" {
method = http.MethodGet
}
expect := s.ExpectedStatus
if expect == 0 {
expect = 200
}
client := &http.Client{Timeout: s.timeout()}
if s.Insecure {
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} //nolint:gosec // opt-in per monitor
}
start := time.Now()
req, err := http.NewRequestWithContext(ctx, method, s.URL, nil)
if err != nil {
return Result{Message: err.Error()}
}
resp, err := client.Do(req)
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
}
defer resp.Body.Close()
res := Result{LatencyMs: msSince(start), Up: true}
if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
exp := resp.TLS.PeerCertificates[0].NotAfter
res.CertExpiry = &exp
}
if resp.StatusCode != expect {
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: fmt.Sprintf("status %d (want %d)", resp.StatusCode, expect)}
}
if s.Keyword != "" {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if !strings.Contains(string(body), s.Keyword) {
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: "keyword not found"}
}
}
return res
}
func runTCP(ctx context.Context, s Spec) Result {
addr := net.JoinHostPort(s.Host, fmt.Sprint(s.Port))
start := time.Now()
d := net.Dialer{Timeout: s.timeout()}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
}
conn.Close()
return Result{Up: true, LatencyMs: msSince(start)}
}
func runTLS(ctx context.Context, s Spec) Result {
port := s.Port
if port == 0 {
port = 443
}
addr := net.JoinHostPort(s.Host, fmt.Sprint(port))
start := time.Now()
d := net.Dialer{Timeout: s.timeout()}
conn, err := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: s.Host})
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
}
defer conn.Close()
certs := conn.ConnectionState().PeerCertificates
if len(certs) == 0 {
return Result{LatencyMs: msSince(start), Message: "no peer certificate"}
}
exp := certs[0].NotAfter
res := Result{LatencyMs: msSince(start), CertExpiry: &exp}
warn := s.TLSWarnDays
if warn <= 0 {
warn = 14
}
remaining := time.Until(exp)
if remaining <= 0 {
res.Message = "certificate expired"
return res
}
if remaining <= time.Duration(warn)*24*time.Hour {
res.Message = fmt.Sprintf("certificate expires in %d days", int(remaining.Hours()/24))
return res
}
res.Up = true
return res
}
func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) }
// runICMP sends a single ICMP echo request and waits for the reply. Requires
// raw-socket privileges (the agent and server run as root). Returns down with a
// descriptive message when the socket cannot be opened or no reply arrives.
func runICMP(ctx context.Context, s Spec) Result {
dst, err := net.ResolveIPAddr("ip4", s.Host)
if err != nil {
return Result{Message: err.Error()}
}
conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return Result{Message: "icmp socket: " + err.Error()}
}
defer conn.Close()
id := os.Getpid() & 0xffff
pkt := icmpEcho(id, 1)
deadline := time.Now().Add(s.timeout())
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
deadline = d
}
_ = conn.SetDeadline(deadline)
start := time.Now()
if _, err := conn.WriteTo(pkt, dst); err != nil {
return Result{Message: err.Error()}
}
reply := make([]byte, 1500)
for {
n, peer, err := conn.ReadFrom(reply)
if err != nil {
return Result{LatencyMs: msSince(start), Message: "no reply"}
}
// Skip the IPv4 header (20 bytes) to reach the ICMP message.
if n < 28 || peer.String() != dst.String() {
continue
}
if reply[20] == 0 { // ICMP echo reply type
return Result{Up: true, LatencyMs: msSince(start)}
}
}
}
func icmpEcho(id, seq int) []byte {
// Type(8)=echo request, Code=0, Checksum, ID, Seq, no payload.
b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)}
cs := icmpChecksum(b)
b[2] = byte(cs >> 8)
b[3] = byte(cs)
return b
}
func icmpChecksum(b []byte) uint16 {
var sum uint32
for i := 0; i < len(b)-1; i += 2 {
sum += uint32(b[i])<<8 | uint32(b[i+1])
}
if len(b)%2 == 1 {
sum += uint32(b[len(b)-1]) << 8
}
for sum>>16 != 0 {
sum = (sum & 0xffff) + (sum >> 16)
}
return ^uint16(sum)
}
-287
View File
@@ -1,287 +0,0 @@
// Hand-written gRPC bindings for keymanager.proto using JSON codec.
// To use: register the JSON codec before creating gRPC servers/clients.
package pb
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Message types
type RegisterRequest struct {
ServerId string `json:"server_id"`
PreRegToken string `json:"pre_reg_token"`
Hostname string `json:"hostname"`
IpAddress string `json:"ip_address"`
OsInfo string `json:"os_info"`
}
type RegisterResponse struct {
AgentToken string `json:"agent_token"`
}
type SyncRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
AgentVersion string `json:"agent_version,omitempty"`
}
type SyncResponse struct {
PublicKeys []string `json:"public_keys"`
}
type UploadKeyRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
PublicKey string `json:"public_key"`
Label string `json:"label"`
PrivateKey string `json:"private_key,omitempty"`
}
type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
// CommandStream message types
type ServerCommand struct {
CommandId string `json:"command_id"`
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
}
type DeleteKeyCmd struct {
Label string `json:"label"`
}
type UpdateAgentCmd struct {
Version string `json:"version"`
GiteaBaseURL string `json:"gitea_base_url"`
}
type GenerateKeyCmd struct {
Label string `json:"label"`
KeyType string `json:"key_type,omitempty"`
KeySize int `json:"key_size,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
Comment string `json:"comment,omitempty"`
}
type AgentMessage struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Ready *AgentReady `json:"ready,omitempty"`
Result *CommandResult `json:"result,omitempty"`
}
type AgentReady struct{}
type CommandResult struct {
CommandId string `json:"command_id"`
Success bool `json:"success"`
Message string `json:"message"`
}
// CommandStream server-side interface
type KeyManager_CommandStreamServer interface {
Send(*ServerCommand) error
Recv() (*AgentMessage, error)
grpc.ServerStream
}
type keyManagerCommandStreamServer struct {
grpc.ServerStream
}
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
return s.ServerStream.SendMsg(m)
}
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
m := new(AgentMessage)
if err := s.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// CommandStream client-side interface
type KeyManager_CommandStreamClient interface {
Send(*AgentMessage) error
Recv() (*ServerCommand, error)
grpc.ClientStream
}
type keyManagerCommandStreamClient struct {
grpc.ClientStream
}
func (c *keyManagerCommandStreamClient) Send(m *AgentMessage) error {
return c.ClientStream.SendMsg(m)
}
func (c *keyManagerCommandStreamClient) Recv() (*ServerCommand, error) {
m := new(ServerCommand)
if err := c.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// Server interface
type KeyManagerServer interface {
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error)
UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error)
CommandStream(KeyManager_CommandStreamServer) error
}
type UnimplementedKeyManagerServer struct{}
func (UnimplementedKeyManagerServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Register not implemented")
}
func (UnimplementedKeyManagerServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SyncKeys not implemented")
}
func (UnimplementedKeyManagerServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UploadGeneratedKey not implemented")
}
func (UnimplementedKeyManagerServer) CommandStream(KeyManager_CommandStreamServer) error {
return status.Errorf(codes.Unimplemented, "method CommandStream not implemented")
}
// Client interface
type KeyManagerClient interface {
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
CommandStream(ctx context.Context, opts ...grpc.CallOption) (KeyManager_CommandStreamClient, error)
}
type keyManagerClient struct {
cc grpc.ClientConnInterface
}
func NewKeyManagerClient(cc grpc.ClientConnInterface) KeyManagerClient {
return &keyManagerClient{cc}
}
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
out := new(RegisterResponse)
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/Register", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
out := new(SyncResponse)
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/SyncKeys", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
out := new(UploadKeyResponse)
if err := c.cc.Invoke(ctx, "/keymanager.v1.KeyManager/UploadGeneratedKey", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (KeyManager_CommandStreamClient, error) {
stream, err := c.cc.NewStream(ctx, &KeyManager_ServiceDesc.Streams[0], "/keymanager.v1.KeyManager/CommandStream", opts...)
if err != nil {
return nil, err
}
return &keyManagerCommandStreamClient{stream}, nil
}
// Server registration
func RegisterKeyManagerServer(s grpc.ServiceRegistrar, srv KeyManagerServer) {
s.RegisterService(&KeyManager_ServiceDesc, srv)
}
var KeyManager_ServiceDesc = grpc.ServiceDesc{
ServiceName: "keymanager.v1.KeyManager",
HandlerType: (*KeyManagerServer)(nil),
Methods: []grpc.MethodDesc{
{MethodName: "Register", Handler: _KeyManager_Register_Handler},
{MethodName: "SyncKeys", Handler: _KeyManager_SyncKeys_Handler},
{MethodName: "UploadGeneratedKey", Handler: _KeyManager_UploadGeneratedKey_Handler},
},
Streams: []grpc.StreamDesc{
{
StreamName: "CommandStream",
Handler: _KeyManager_CommandStream_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "keymanager/v1/keymanager.proto",
}
func _KeyManager_Register_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RegisterRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KeyManagerServer).Register(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/keymanager.v1.KeyManager/Register"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KeyManagerServer).Register(ctx, req.(*RegisterRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KeyManager_SyncKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SyncRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KeyManagerServer).SyncKeys(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/keymanager.v1.KeyManager/SyncKeys"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KeyManagerServer).SyncKeys(ctx, req.(*SyncRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KeyManager_UploadGeneratedKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UploadKeyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(KeyManagerServer).UploadGeneratedKey(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/keymanager.v1.KeyManager/UploadGeneratedKey"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(KeyManagerServer).UploadGeneratedKey(ctx, req.(*UploadKeyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _KeyManager_CommandStream_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(KeyManagerServer).CommandStream(&keyManagerCommandStreamServer{stream})
}
+528
View File
@@ -0,0 +1,528 @@
// Hand-written gRPC bindings for vantage.proto using JSON codec.
// To use: register the JSON codec before creating gRPC servers/clients.
package pb
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Message types
type RegisterRequest struct {
ServerId string `json:"server_id"`
PreRegToken string `json:"pre_reg_token"`
Hostname string `json:"hostname"`
IpAddress string `json:"ip_address"`
OsInfo string `json:"os_info"`
}
type RegisterResponse struct {
AgentToken string `json:"agent_token"`
}
type SyncRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
AgentVersion string `json:"agent_version,omitempty"`
}
type SyncResponse struct {
PublicKeys []string `json:"public_keys"`
}
type UploadKeyRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
PublicKey string `json:"public_key"`
Label string `json:"label"`
PrivateKey string `json:"private_key,omitempty"`
}
type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
// CommandStream message types
type PackageUpdate struct {
Name string `json:"name"`
CurrentVersion string `json:"current_version,omitempty"`
NewVersion string `json:"new_version"`
}
type ReportUpdatesRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Updates []PackageUpdate `json:"updates"`
}
type ReportUpdatesResponse struct{}
// Inventory report message types
type CPUReport struct {
Model string `json:"model,omitempty"`
Cores int `json:"cores,omitempty"`
UsagePct float64 `json:"usage_pct"`
Load1 float64 `json:"load1,omitempty"`
}
type MemReport struct {
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
}
type PartitionReport struct {
Device string `json:"device"`
Mountpoint string `json:"mountpoint"`
Fstype string `json:"fstype,omitempty"`
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
}
type InventoryReport struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
IncludeStatic bool `json:"include_static"`
CPU *CPUReport `json:"cpu,omitempty"`
Memory *MemReport `json:"memory,omitempty"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
}
type InventoryReportResponse struct{}
// Monitor sync / check report message types
type MonitorSpec struct {
MonitorId string `json:"monitor_id"`
Type string `json:"type"`
URL string `json:"url,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Method string `json:"method,omitempty"`
ExpectedStatus int `json:"expected_status,omitempty"`
Keyword string `json:"keyword,omitempty"`
TLSWarnDays int `json:"tls_warn_days,omitempty"`
Insecure bool `json:"insecure,omitempty"`
IntervalSec int `json:"interval_sec"`
Retries int `json:"retries"`
}
type SyncMonitorsRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
}
type SyncMonitorsResponse struct {
Monitors []MonitorSpec `json:"monitors,omitempty"`
}
type CheckResult struct {
MonitorId string `json:"monitor_id"`
Up bool `json:"up"`
LatencyMs int `json:"latency_ms"`
Message string `json:"message,omitempty"`
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
}
type ReportChecksRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Results []CheckResult `json:"results,omitempty"`
}
type ReportChecksResponse struct{}
type ApplyUpdatesCmd struct{}
type ServerCommand struct {
CommandId string `json:"command_id"`
GenerateKey *GenerateKeyCmd `json:"generate_key,omitempty"`
DeleteKey *DeleteKeyCmd `json:"delete_key,omitempty"`
UpdateAgent *UpdateAgentCmd `json:"update_agent,omitempty"`
ApplyUpdates *ApplyUpdatesCmd `json:"apply_updates,omitempty"`
RunStep *RunStepCmd `json:"run_step,omitempty"`
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
}
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
// directory once all steps on that server have finished.
type CleanupWorkspaceCmd struct {
WorkspaceId string `json:"workspace_id"`
}
type DeleteKeyCmd struct {
Label string `json:"label"`
}
type UpdateAgentCmd struct {
Version string `json:"version"`
GiteaBaseURL string `json:"gitea_base_url"`
}
type GenerateKeyCmd struct {
Label string `json:"label"`
KeyType string `json:"key_type,omitempty"`
KeySize int `json:"key_size,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
Comment string `json:"comment,omitempty"`
}
type AgentMessage struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Ready *AgentReady `json:"ready,omitempty"`
Result *CommandResult `json:"result,omitempty"`
StepResult *StepResult `json:"step_result,omitempty"`
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
}
type AgentReady struct{}
type CommandResult struct {
CommandId string `json:"command_id"`
Success bool `json:"success"`
Message string `json:"message"`
}
type RunStepCmd struct {
Interpreter string `json:"interpreter"`
Script string `json:"script"`
Env map[string]string `json:"env,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
// WorkspaceId names the per-run working directory the agent creates and uses
// as the step's cwd. Empty means run in the agent's default directory.
WorkspaceId string `json:"workspace_id,omitempty"`
}
type StepResult struct {
CommandId string `json:"command_id"`
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
OutputEnv map[string]string `json:"output_env,omitempty"`
}
type StepOutputChunk struct {
CommandId string `json:"command_id"`
Seq uint64 `json:"seq"`
Data []byte `json:"data,omitempty"`
Eof bool `json:"eof,omitempty"`
}
// CommandStream server-side interface
type Vantage_CommandStreamServer interface {
Send(*ServerCommand) error
Recv() (*AgentMessage, error)
grpc.ServerStream
}
type keyManagerCommandStreamServer struct {
grpc.ServerStream
}
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
return s.ServerStream.SendMsg(m)
}
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
m := new(AgentMessage)
if err := s.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// CommandStream client-side interface
type Vantage_CommandStreamClient interface {
Send(*AgentMessage) error
Recv() (*ServerCommand, error)
grpc.ClientStream
}
type vantageCommandStreamClient struct {
grpc.ClientStream
}
func (c *vantageCommandStreamClient) Send(m *AgentMessage) error {
return c.ClientStream.SendMsg(m)
}
func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
m := new(ServerCommand)
if err := c.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// Server interface
type VantageServer interface {
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error)
UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error)
ReportUpdates(context.Context, *ReportUpdatesRequest) (*ReportUpdatesResponse, error)
ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error)
SyncMonitors(context.Context, *SyncMonitorsRequest) (*SyncMonitorsResponse, error)
ReportChecks(context.Context, *ReportChecksRequest) (*ReportChecksResponse, error)
CommandStream(Vantage_CommandStreamServer) error
}
type UnimplementedVantageServer struct{}
func (UnimplementedVantageServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Register not implemented")
}
func (UnimplementedVantageServer) SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SyncKeys not implemented")
}
func (UnimplementedVantageServer) UploadGeneratedKey(context.Context, *UploadKeyRequest) (*UploadKeyResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method UploadGeneratedKey not implemented")
}
func (UnimplementedVantageServer) ReportUpdates(context.Context, *ReportUpdatesRequest) (*ReportUpdatesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReportUpdates not implemented")
}
func (UnimplementedVantageServer) ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReportInventory not implemented")
}
func (UnimplementedVantageServer) SyncMonitors(context.Context, *SyncMonitorsRequest) (*SyncMonitorsResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SyncMonitors not implemented")
}
func (UnimplementedVantageServer) ReportChecks(context.Context, *ReportChecksRequest) (*ReportChecksResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReportChecks not implemented")
}
func (UnimplementedVantageServer) CommandStream(Vantage_CommandStreamServer) error {
return status.Errorf(codes.Unimplemented, "method CommandStream not implemented")
}
// Client interface
type VantageClient interface {
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error)
ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error)
ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error)
SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error)
ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error)
CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error)
}
type keyManagerClient struct {
cc grpc.ClientConnInterface
}
func NewVantageClient(cc grpc.ClientConnInterface) VantageClient {
return &keyManagerClient{cc}
}
func (c *keyManagerClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) {
out := new(RegisterResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/Register", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error) {
out := new(SyncResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncKeys", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) UploadGeneratedKey(ctx context.Context, in *UploadKeyRequest, opts ...grpc.CallOption) (*UploadKeyResponse, error) {
out := new(UploadKeyResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/UploadGeneratedKey", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) ReportUpdates(ctx context.Context, in *ReportUpdatesRequest, opts ...grpc.CallOption) (*ReportUpdatesResponse, error) {
out := new(ReportUpdatesResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportUpdates", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) ReportInventory(ctx context.Context, in *InventoryReport, opts ...grpc.CallOption) (*InventoryReportResponse, error) {
out := new(InventoryReportResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportInventory", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) SyncMonitors(ctx context.Context, in *SyncMonitorsRequest, opts ...grpc.CallOption) (*SyncMonitorsResponse, error) {
out := new(SyncMonitorsResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/SyncMonitors", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) ReportChecks(ctx context.Context, in *ReportChecksRequest, opts ...grpc.CallOption) (*ReportChecksResponse, error) {
out := new(ReportChecksResponse)
if err := c.cc.Invoke(ctx, "/vantage.v1.Vantage/ReportChecks", in, out, opts...); err != nil {
return nil, err
}
return out, nil
}
func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallOption) (Vantage_CommandStreamClient, error) {
stream, err := c.cc.NewStream(ctx, &Vantage_ServiceDesc.Streams[0], "/vantage.v1.Vantage/CommandStream", opts...)
if err != nil {
return nil, err
}
return &vantageCommandStreamClient{stream}, nil
}
// Server registration
func RegisterVantageServer(s grpc.ServiceRegistrar, srv VantageServer) {
s.RegisterService(&Vantage_ServiceDesc, srv)
}
var Vantage_ServiceDesc = grpc.ServiceDesc{
ServiceName: "vantage.v1.Vantage",
HandlerType: (*VantageServer)(nil),
Methods: []grpc.MethodDesc{
{MethodName: "Register", Handler: _Vantage_Register_Handler},
{MethodName: "SyncKeys", Handler: _Vantage_SyncKeys_Handler},
{MethodName: "UploadGeneratedKey", Handler: _Vantage_UploadGeneratedKey_Handler},
{MethodName: "ReportUpdates", Handler: _Vantage_ReportUpdates_Handler},
{MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler},
{MethodName: "SyncMonitors", Handler: _Vantage_SyncMonitors_Handler},
{MethodName: "ReportChecks", Handler: _Vantage_ReportChecks_Handler},
},
Streams: []grpc.StreamDesc{
{
StreamName: "CommandStream",
Handler: _Vantage_CommandStream_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "vantage/v1/vantage.proto",
}
func _Vantage_Register_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RegisterRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VantageServer).Register(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/Register"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VantageServer).Register(ctx, req.(*RegisterRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Vantage_SyncKeys_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SyncRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VantageServer).SyncKeys(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/SyncKeys"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VantageServer).SyncKeys(ctx, req.(*SyncRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Vantage_UploadGeneratedKey_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UploadKeyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VantageServer).UploadGeneratedKey(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/UploadGeneratedKey"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VantageServer).UploadGeneratedKey(ctx, req.(*UploadKeyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Vantage_ReportUpdates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReportUpdatesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VantageServer).ReportUpdates(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportUpdates"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VantageServer).ReportUpdates(ctx, req.(*ReportUpdatesRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Vantage_ReportInventory_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(InventoryReport)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VantageServer).ReportInventory(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportInventory"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VantageServer).ReportInventory(ctx, req.(*InventoryReport))
}
return interceptor(ctx, in, info, handler)
}
func _Vantage_SyncMonitors_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SyncMonitorsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VantageServer).SyncMonitors(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/SyncMonitors"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VantageServer).SyncMonitors(ctx, req.(*SyncMonitorsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Vantage_ReportChecks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReportChecksRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(VantageServer).ReportChecks(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: "/vantage.v1.Vantage/ReportChecks"}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(VantageServer).ReportChecks(ctx, req.(*ReportChecksRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Vantage_CommandStream_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(VantageServer).CommandStream(&keyManagerCommandStreamServer{stream})
}
+120 -11
View File
@@ -5,12 +5,16 @@ import (
"fmt"
"log"
"net"
"time"
"github.com/mrhid6/keymanager/server/internal/grpc/pb"
"github.com/mrhid6/keymanager/server/internal/services"
"github.com/mrhid6/vantage/server/internal/checker"
"github.com/mrhid6/vantage/server/internal/grpc/pb"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/encoding"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/status"
)
@@ -18,11 +22,11 @@ func init() {
encoding.RegisterCodec(JSONCodec{})
}
type keyManagerServer struct {
pb.UnimplementedKeyManagerServer
type vantageServer struct {
pb.UnimplementedVantageServer
}
func (s *keyManagerServer) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) {
func (s *vantageServer) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) {
agentToken, err := services.RegisterServer(req.ServerId, req.PreRegToken, req.Hostname, req.IpAddress, req.OsInfo)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "registration failed: %v", err)
@@ -30,7 +34,7 @@ func (s *keyManagerServer) Register(ctx context.Context, req *pb.RegisterRequest
return &pb.RegisterResponse{AgentToken: agentToken}, nil
}
func (s *keyManagerServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (*pb.SyncResponse, error) {
func (s *vantageServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (*pb.SyncResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
@@ -40,6 +44,10 @@ func (s *keyManagerServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (*
log.Printf("failed to update last seen for %s: %v", srv.ServerID, err)
}
if err := services.BackfillConsoleConfig(srv); err != nil {
log.Printf("failed to backfill console config for %s: %v", srv.ServerID, err)
}
keys, err := services.BuildAuthorizedKeys(req.ServerId)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to build authorized keys: %v", err)
@@ -48,13 +56,14 @@ func (s *keyManagerServer) SyncKeys(ctx context.Context, req *pb.SyncRequest) (*
return &pb.SyncResponse{PublicKeys: keys}, nil
}
func (s *keyManagerServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKeyRequest) (*pb.UploadKeyResponse, error) {
func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKeyRequest) (*pb.UploadKeyResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey)
// Agent-generated keys carry no passphrase over the wire (proto has no field).
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "")
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to store key: %v", err)
}
@@ -67,7 +76,84 @@ func (s *keyManagerServer) UploadGeneratedKey(ctx context.Context, req *pb.Uploa
return &pb.UploadKeyResponse{KeyId: key.KeyID}, nil
}
func (s *keyManagerServer) CommandStream(stream pb.KeyManager_CommandStreamServer) error {
func (s *vantageServer) ReportUpdates(ctx context.Context, req *pb.ReportUpdatesRequest) (*pb.ReportUpdatesResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
pkgs := make([]models.PackageUpdate, len(req.Updates))
for i, u := range req.Updates {
pkgs[i] = models.PackageUpdate{
Name: u.Name,
CurrentVersion: u.CurrentVersion,
NewVersion: u.NewVersion,
}
}
if err := services.StoreAvailableUpdates(srv.ServerID, pkgs); err != nil {
log.Printf("failed to store updates for %s: %v", srv.ServerID, err)
}
return &pb.ReportUpdatesResponse{}, nil
}
func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryReport) (*pb.InventoryReportResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
if err := services.StoreInventory(srv.ServerID, req); err != nil {
log.Printf("store inventory for %s: %v", srv.ServerID, err)
}
return &pb.InventoryReportResponse{}, nil
}
func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRequest) (*pb.SyncMonitorsResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
monitors, err := services.ListMonitorsForRunner(srv.ServerID)
if err != nil {
return nil, status.Errorf(codes.Internal, "list monitors")
}
specs := make([]pb.MonitorSpec, 0, len(monitors))
for _, m := range monitors {
specs = append(specs, pb.MonitorSpec{
MonitorId: m.MonitorID,
Type: m.Type,
URL: m.Target.URL,
Host: m.Target.Host,
Port: m.Target.Port,
Method: m.Target.Method,
ExpectedStatus: m.Target.ExpectedStatus,
Keyword: m.Target.Keyword,
TLSWarnDays: m.Target.TLSWarnDays,
Insecure: m.Target.Insecure,
IntervalSec: m.IntervalSec,
Retries: m.Retries,
})
}
return &pb.SyncMonitorsResponse{Monitors: specs}, nil
}
func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRequest) (*pb.ReportChecksResponse, error) {
if _, err := services.ValidateAgentToken(req.ServerId, req.AgentToken); err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
for _, r := range req.Results {
res := checker.Result{Up: r.Up, LatencyMs: r.LatencyMs, Message: r.Message}
if r.CertExpiryUnix > 0 {
t := time.Unix(r.CertExpiryUnix, 0)
res.CertExpiry = &t
}
if err := services.IngestResult(r.MonitorId, res); err != nil {
log.Printf("ingest check %s: %v", r.MonitorId, err)
}
}
return &pb.ReportChecksResponse{}, nil
}
func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) error {
// First message authenticates the agent and signals readiness.
msg, err := stream.Recv()
if err != nil {
@@ -101,6 +187,16 @@ func (s *keyManagerServer) CommandStream(stream pb.KeyManager_CommandStreamServe
r := m.Result
log.Printf("agent %s cmd %s: success=%v %s", srv.ServerID, r.CommandId, r.Success, r.Message)
}
if m.StepResult != nil {
services.StepResults.Deliver(m.StepResult)
}
if m.StepOutput != nil {
if m.StepOutput.Eof {
services.StepLogs.Close(m.StepOutput.CommandId)
} else {
services.StepLogs.Append(m.StepOutput.CommandId, m.StepOutput.Data)
}
}
}
}()
@@ -126,8 +222,21 @@ func StartGRPC(port int) error {
return fmt.Errorf("failed to listen: %w", err)
}
s := grpc.NewServer()
pb.RegisterKeyManagerServer(s, &keyManagerServer{})
s := grpc.NewServer(
// Accept client keepalive pings as fast as every 20s so the 30s agent
// ping interval is always within the allowed window.
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 20 * time.Second,
PermitWithoutStream: false,
}),
grpc.KeepaliveParams(keepalive.ServerParameters{
// Server also pings the client after 45s of inactivity so both
// sides can detect a dead connection without waiting for a timeout.
Time: 45 * time.Second,
Timeout: 10 * time.Second,
}),
)
pb.RegisterVantageServer(s, &vantageServer{})
log.Printf("gRPC server listening on :%d", port)
return s.Serve(lis)
+17
View File
@@ -0,0 +1,17 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AuditEvent struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
EventType string `bson:"event_type" json:"event_type"`
Actor string `bson:"actor" json:"actor"`
ServerID string `bson:"server_id,omitempty" json:"server_id,omitempty"`
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
Details string `bson:"details" json:"details"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+29
View File
@@ -0,0 +1,29 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Notification channel types.
const (
ChannelWebhook = "webhook"
ChannelSMTP = "smtp"
ChannelDiscord = "discord"
ChannelSlack = "slack"
ChannelTelegram = "telegram"
)
// NotificationChannel is an outbound alert destination. Config holds
// type-specific settings (e.g. url; or smtp host/port/username/password/from/to;
// or telegram token/chat_id).
type NotificationChannel struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
ChannelID string `bson:"channel_id" json:"channel_id"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"`
Config map[string]string `bson:"config" json:"config"`
Enabled bool `bson:"enabled" json:"enabled"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+28
View File
@@ -0,0 +1,28 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type ConsoleSession struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
SessionID string `bson:"session_id" json:"session_id"`
ServerID string `bson:"server_id" json:"server_id"`
Protocol string `bson:"protocol" json:"protocol"` // ssh | rdp | vnc
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
User string `bson:"user" json:"user"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"`
ClientIP string `bson:"client_ip,omitempty" json:"client_ip,omitempty"`
// TokenConsumedAt marks the one-time session token as spent. Set atomically
// when the tunnel opens; a second open with the same token is rejected.
TokenConsumedAt *time.Time `bson:"token_consumed_at,omitempty" json:"-"`
SSHUsername string `bson:"ssh_username,omitempty" json:"ssh_username,omitempty"`
RDPUserEnc string `bson:"rdp_user_enc,omitempty" json:"-"`
RDPPassEnc string `bson:"rdp_pass_enc,omitempty" json:"-"`
}
+2
View File
@@ -16,5 +16,7 @@ type Key struct {
GeneratedByServerID string `bson:"generated_by_server_id,omitempty" json:"generated_by_server_id,omitempty"`
PrivateKeyEncrypted string `bson:"private_key_enc,omitempty" json:"-"`
HasPrivateKey bool `bson:"-" json:"has_private_key"`
PassphraseEncrypted string `bson:"passphrase_enc,omitempty" json:"-"`
HasPassphrase bool `bson:"-" json:"has_passphrase"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+78
View File
@@ -0,0 +1,78 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Monitor check types.
const (
MonitorHTTP = "http"
MonitorTCP = "tcp"
MonitorICMP = "icmp"
MonitorTLS = "tls"
)
// Monitor status values.
const (
StatusUp = "up"
StatusDown = "down"
StatusPending = "pending"
)
// RunnerServer is the reserved Runner value for server-run monitors. Any other
// value is treated as a server_id whose agent runs the check locally.
const RunnerServer = "server"
type MonitorTarget struct {
URL string `bson:"url,omitempty" json:"url,omitempty"`
Host string `bson:"host,omitempty" json:"host,omitempty"`
Port int `bson:"port,omitempty" json:"port,omitempty"`
Method string `bson:"method,omitempty" json:"method,omitempty"`
ExpectedStatus int `bson:"expected_status,omitempty" json:"expected_status,omitempty"`
Keyword string `bson:"keyword,omitempty" json:"keyword,omitempty"`
TLSWarnDays int `bson:"tls_warn_days,omitempty" json:"tls_warn_days,omitempty"`
Insecure bool `bson:"insecure,omitempty" json:"insecure,omitempty"` // skip TLS cert verification (HTTP monitors)
}
type MonitorState struct {
Status string `bson:"status" json:"status"` // up|down|pending
LastCheckAt *time.Time `bson:"last_check_at,omitempty" json:"last_check_at,omitempty"`
LatencyMs int `bson:"latency_ms" json:"latency_ms"`
Message string `bson:"message,omitempty" json:"message,omitempty"`
CertExpiryAt *time.Time `bson:"cert_expiry_at,omitempty" json:"cert_expiry_at,omitempty"`
Fails int `bson:"fails" json:"fails"` // consecutive failures
LastNotifiedAt *time.Time `bson:"last_notified_at,omitempty" json:"last_notified_at,omitempty"`
}
type Monitor struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"` // http|tcp|icmp|tls
Target MonitorTarget `bson:"target" json:"target"`
IntervalSec int `bson:"interval_sec" json:"interval_sec"`
Runner string `bson:"runner" json:"runner"` // "server" or a server_id
Retries int `bson:"retries" json:"retries"` // consecutive fails before down
Enabled bool `bson:"enabled" json:"enabled"`
ChannelIDs []string `bson:"channel_ids,omitempty" json:"channel_ids,omitempty"`
State MonitorState `bson:"state" json:"state"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type Incident struct {
IncidentID string `bson:"incident_id" json:"incident_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
ResolvedAt *time.Time `bson:"resolved_at,omitempty" json:"resolved_at,omitempty"`
Cause string `bson:"cause,omitempty" json:"cause,omitempty"`
}
type Rollup struct {
MonitorID string `bson:"monitor_id" json:"monitor_id"`
PeriodStart time.Time `bson:"period_start" json:"period_start"` // hour bucket
Checks int `bson:"checks" json:"checks"`
UpCount int `bson:"up_count" json:"up_count"`
SumLatency int64 `bson:"sum_latency" json:"sum_latency"`
}
+24
View File
@@ -0,0 +1,24 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Secret is a single key/value pair within a group. The value is stored
// encrypted (AES-256-GCM) and is never serialized to JSON.
type Secret struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Group string `bson:"group" json:"group"`
Key string `bson:"key" json:"key"`
EncryptedValue string `bson:"encrypted_value" json:"-"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// GroupSummary describes a group in the list view.
type GroupSummary struct {
Group string `json:"group"`
KeyCount int `json:"key_count"`
UpdatedAt time.Time `json:"updated_at"`
}
+57 -13
View File
@@ -6,17 +6,61 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
)
type Server struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
IPAddress string `bson:"ip_address" json:"ip_address"`
OSInfo string `bson:"os_info" json:"os_info"`
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
Status string `bson:"status" json:"status"`
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
type PackageUpdate struct {
Name string `bson:"name" json:"name"`
CurrentVersion string `bson:"current_version,omitempty" json:"current_version,omitempty"`
NewVersion string `bson:"new_version" json:"new_version"`
}
type CPUInfo struct {
Model string `bson:"model,omitempty" json:"model,omitempty"`
Cores int `bson:"cores,omitempty" json:"cores,omitempty"`
UsagePct float64 `bson:"usage_pct" json:"usage_pct"`
Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"`
}
type MemInfo struct {
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
}
type Partition struct {
Device string `bson:"device" json:"device"`
Mountpoint string `bson:"mountpoint" json:"mountpoint"`
Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"`
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
}
type Inventory struct {
CPU CPUInfo `bson:"cpu" json:"cpu"`
Memory MemInfo `bson:"memory" json:"memory"`
SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"`
SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"`
Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"`
Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"`
MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
}
type Server struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
IPAddress string `bson:"ip_address" json:"ip_address"`
OSInfo string `bson:"os_info" json:"os_info"`
OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"`
ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"`
SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"`
RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"`
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
Status string `bson:"status" json:"status"`
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
AvailableUpdates []PackageUpdate `bson:"available_updates,omitempty" json:"available_updates,omitempty"`
UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"`
Inventory *Inventory `bson:"inventory,omitempty" json:"inventory,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+41
View File
@@ -0,0 +1,41 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AlertSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
WebhookURL string `bson:"webhook_url" json:"webhook_url"`
OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"`
}
type EmailSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
SMTPHost string `bson:"smtp_host" json:"smtp_host"`
SMTPPort int `bson:"smtp_port" json:"smtp_port"`
Username string `bson:"username" json:"username"`
Password string `bson:"password" json:"password"`
FromAddr string `bson:"from_addr" json:"from_addr"`
ToAddrs []string `bson:"to_addrs" json:"to_addrs"`
UseTLS bool `bson:"use_tls" json:"use_tls"`
}
// SecretsSettings holds configuration for the secrets vault / ESO integration.
// The read token is stored as a SHA-256 hash and never returned to clients.
type SecretsSettings struct {
ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"`
ReadTokenSet bool `bson:"-" json:"read_token_set"`
RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"`
}
type Settings struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
Alerts AlertSettings `bson:"alerts" json:"alerts"`
Email EmailSettings `bson:"email" json:"email"`
Secrets SecretsSettings `bson:"secrets" json:"secrets"`
// WorkflowLogRetentionDays: nil = default 30, 0 = keep forever, N = N days.
WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"`
}
+101
View File
@@ -0,0 +1,101 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type InputParam struct {
Name string `bson:"name" json:"name"`
Default string `bson:"default" json:"default"`
Description string `bson:"description" json:"description"`
}
type WorkflowStep struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
StepID string `bson:"step_id" json:"step_id"`
Name string `bson:"name" json:"name"`
Description string `bson:"description" json:"description"`
Interpreter string `bson:"interpreter" json:"interpreter"` // "bash" | "powershell"
Script string `bson:"script" json:"script"`
DeclaredOutputs []string `bson:"declared_outputs" json:"declared_outputs"`
DeclaredInputs []InputParam `bson:"declared_inputs" json:"declared_inputs"`
SecretRefs []string `bson:"secret_refs" json:"secret_refs"`
Source string `bson:"source" json:"source"` // "user" | "default"
Slug string `bson:"slug,omitempty" json:"slug,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type WorkflowStepRef struct {
StepID string `bson:"step_id,omitempty" json:"step_id,omitempty"`
Inline *WorkflowStep `bson:"inline,omitempty" json:"inline,omitempty"`
Order int `bson:"order" json:"order"`
OnFailure string `bson:"on_failure" json:"on_failure"` // "stop" | "continue" | "retry"
MaxRetries int `bson:"max_retries" json:"max_retries"`
Overrides *StepOverride `bson:"overrides,omitempty" json:"overrides,omitempty"`
Inputs map[string]string `bson:"inputs,omitempty" json:"inputs,omitempty"`
}
type StepOverride struct {
Script *string `bson:"script,omitempty" json:"script,omitempty"`
SecretRefs []string `bson:"secret_refs,omitempty" json:"secret_refs,omitempty"`
}
type Workflow struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
WorkflowID string `bson:"workflow_id" json:"workflow_id"`
Name string `bson:"name" json:"name"`
TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"`
Steps []WorkflowStepRef `bson:"steps" json:"steps"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// ResolvedStep is a step frozen into a run snapshot (library step + overrides applied).
type ResolvedStep struct {
Order int `bson:"order" json:"order"`
Name string `bson:"name" json:"name"`
Interpreter string `bson:"interpreter" json:"interpreter"`
Script string `bson:"script" json:"script"`
SecretRefs []string `bson:"secret_refs" json:"secret_refs"`
OnFailure string `bson:"on_failure" json:"on_failure"`
MaxRetries int `bson:"max_retries" json:"max_retries"`
Inputs map[string]string `bson:"inputs" json:"inputs"`
}
type StepRun struct {
Order int `bson:"order" json:"order"`
Name string `bson:"name" json:"name"`
Status string `bson:"status" json:"status"` // queued|running|success|failed|skipped
Attempts int `bson:"attempts" json:"attempts"`
ExitCode int `bson:"exit_code" json:"exit_code"`
LogOffset int64 `bson:"log_offset" json:"log_offset"`
OutputEnv map[string]string `bson:"output_env" json:"output_env"`
StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
}
type ServerRun struct {
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
Status string `bson:"status" json:"status"` // queued|running|success|failed|skipped
StartedAt *time.Time `bson:"started_at,omitempty" json:"started_at,omitempty"`
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
RunEnv map[string]string `bson:"run_env" json:"run_env"`
Steps []StepRun `bson:"steps" json:"steps"`
}
type WorkflowRun struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
RunID string `bson:"run_id" json:"run_id"`
WorkflowID string `bson:"workflow_id" json:"workflow_id"`
Name string `bson:"name" json:"name"`
Steps []ResolvedStep `bson:"steps_snapshot" json:"steps_snapshot"`
Status string `bson:"status" json:"status"` // running|success|failed|cancelled
TriggeredBy string `bson:"triggered_by" json:"triggered_by"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
FinishedAt *time.Time `bson:"finished_at,omitempty" json:"finished_at,omitempty"`
ServerRuns []ServerRun `bson:"server_runs" json:"server_runs"`
}
+107
View File
@@ -0,0 +1,107 @@
// Package monitorsched runs server-side monitors on their configured interval
// and funnels results through services.IngestResult. Agent-run monitors
// (runner != "server") are excluded — those execute on the agent.
package monitorsched
import (
"context"
"log"
"sync"
"time"
"github.com/mrhid6/vantage/server/internal/checker"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
// reloadInterval controls how often the scheduler re-reads monitor definitions
// so CRUD changes (new/removed/edited monitors) take effect.
const reloadInterval = 30 * time.Second
type runner struct {
monitorID string
intervalSec int
cancel context.CancelFunc
}
// Start launches the scheduler loop. It returns immediately; the loop runs until
// ctx is cancelled.
func Start(ctx context.Context) {
go loop(ctx)
}
func loop(ctx context.Context) {
active := map[string]*runner{}
var mu sync.Mutex
sync := func() {
monitors, err := services.ListMonitorsForRunner(models.RunnerServer)
if err != nil {
log.Printf("monitorsched: list monitors: %v", err)
return
}
want := map[string]models.Monitor{}
for _, m := range monitors {
want[m.MonitorID] = m
}
mu.Lock()
defer mu.Unlock()
// Stop runners for monitors that vanished or changed interval.
for id, r := range active {
m, ok := want[id]
if !ok || m.IntervalSec != r.intervalSec {
r.cancel()
delete(active, id)
}
}
// Start runners for new/changed monitors.
for id, m := range want {
if _, ok := active[id]; ok {
continue
}
rctx, cancel := context.WithCancel(ctx)
active[id] = &runner{monitorID: id, intervalSec: m.IntervalSec, cancel: cancel}
go runMonitor(rctx, m)
}
}
sync()
t := time.NewTicker(reloadInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
sync()
}
}
}
func runMonitor(ctx context.Context, m models.Monitor) {
interval := time.Duration(m.IntervalSec) * time.Second
if interval <= 0 {
interval = 60 * time.Second
}
spec := services.SpecFor(&m)
run := func() {
res := checker.Run(ctx, spec)
if err := services.IngestResult(m.MonitorID, res); err != nil {
log.Printf("monitorsched: ingest %s: %v", m.MonitorID, err)
}
}
run() // check immediately on (re)start
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
run()
}
}
}
+64
View File
@@ -0,0 +1,64 @@
// Package notify formats and delivers monitor state-change alerts to
// notification channels. It depends only on models so services can call it
// without an import cycle.
package notify
import (
"fmt"
"time"
"github.com/mrhid6/vantage/server/internal/models"
)
// Event describes a monitor state transition worth alerting on.
type Event struct {
MonitorName string
Type string
OldStatus string
NewStatus string
Message string
Time time.Time
}
// title is a short one-line summary used by the text-based channels.
func (e Event) title() string {
verb := "recovered"
if e.NewStatus == models.StatusDown {
verb = "is DOWN"
}
s := fmt.Sprintf("[Vantage] %s (%s) %s", e.MonitorName, e.Type, verb)
if e.Message != "" {
s += ": " + e.Message
}
return s
}
// Dispatch delivers ev to a single channel, formatting per channel type.
func Dispatch(ch models.NotificationChannel, ev Event) error {
switch ch.Type {
case models.ChannelWebhook:
return dispatchWebhook(ch, ev)
case models.ChannelDiscord:
return dispatchDiscord(ch, ev)
case models.ChannelSlack:
return dispatchSlack(ch, ev)
case models.ChannelTelegram:
return dispatchTelegram(ch, ev)
case models.ChannelSMTP:
return dispatchSMTP(ch, ev)
default:
return fmt.Errorf("unknown channel type: %s", ch.Type)
}
}
// Test delivers a synthetic event so users can verify a channel's configuration.
func Test(ch models.NotificationChannel) error {
return Dispatch(ch, Event{
MonitorName: "Test monitor",
Type: "http",
OldStatus: models.StatusUp,
NewStatus: models.StatusDown,
Message: "this is a test alert from Vantage",
Time: time.Now(),
})
}
+71
View File
@@ -0,0 +1,71 @@
package notify
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/mrhid6/vantage/server/internal/models"
)
var httpClient = &http.Client{Timeout: 10 * time.Second}
func postJSON(target string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := httpClient.Post(target, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, target)
}
return nil
}
// dispatchWebhook posts the full event as JSON to a user-supplied URL.
func dispatchWebhook(ch models.NotificationChannel, ev Event) error {
target := ch.Config["url"]
if target == "" {
return fmt.Errorf("webhook: missing url")
}
return postJSON(target, map[string]any{
"monitor": ev.MonitorName,
"type": ev.Type,
"old_status": ev.OldStatus,
"new_status": ev.NewStatus,
"message": ev.Message,
"time": ev.Time.Format(time.RFC3339),
})
}
func dispatchDiscord(ch models.NotificationChannel, ev Event) error {
target := ch.Config["url"]
if target == "" {
return fmt.Errorf("discord: missing url")
}
return postJSON(target, map[string]string{"content": ev.title()})
}
func dispatchSlack(ch models.NotificationChannel, ev Event) error {
target := ch.Config["url"]
if target == "" {
return fmt.Errorf("slack: missing url")
}
return postJSON(target, map[string]string{"text": ev.title()})
}
func dispatchTelegram(ch models.NotificationChannel, ev Event) error {
token := ch.Config["token"]
chatID := ch.Config["chat_id"]
if token == "" || chatID == "" {
return fmt.Errorf("telegram: missing token or chat_id")
}
api := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token)
return postJSON(api, map[string]string{"chat_id": chatID, "text": ev.title()})
}
+98
View File
@@ -0,0 +1,98 @@
package notify
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
"time"
"github.com/mrhid6/vantage/server/internal/models"
)
const smtpTimeout = 15 * time.Second
// dispatchSMTP sends the alert as a plain-text email. Config keys: host, port,
// username, password, from, to. Auth is skipped when username is empty. Port 465
// uses implicit TLS; other ports use STARTTLS when the server advertises it.
//
// It dials with a timeout and sets a connection deadline so an unreachable or
// misconfigured SMTP host fails fast instead of hanging the request until the OS
// TCP timeout (which resets the upstream proxy connection).
func dispatchSMTP(ch models.NotificationChannel, ev Event) error {
host := ch.Config["host"]
port := ch.Config["port"]
from := ch.Config["from"]
to := ch.Config["to"]
if host == "" || port == "" || from == "" || to == "" {
return fmt.Errorf("smtp: missing host/port/from/to")
}
addr := net.JoinHostPort(host, port)
conn, err := net.DialTimeout("tcp", addr, smtpTimeout)
if err != nil {
return fmt.Errorf("smtp: dial %s: %w", addr, err)
}
_ = conn.SetDeadline(time.Now().Add(smtpTimeout))
// Implicit TLS on 465; otherwise start plain and upgrade via STARTTLS.
if port == "465" {
conn = tls.Client(conn, &tls.Config{ServerName: host})
}
c, err := smtp.NewClient(conn, host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp: client: %w", err)
}
defer c.Close()
if port != "465" {
if ok, _ := c.Extension("STARTTLS"); ok {
if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil {
return fmt.Errorf("smtp: starttls: %w", err)
}
}
}
if user := ch.Config["username"]; user != "" {
if err := c.Auth(smtp.PlainAuth("", user, ch.Config["password"], host)); err != nil {
return fmt.Errorf("smtp: auth: %w", err)
}
}
recipients := strings.Split(to, ",")
for i := range recipients {
recipients[i] = strings.TrimSpace(recipients[i])
}
if err := c.Mail(from); err != nil {
return fmt.Errorf("smtp: mail from: %w", err)
}
for _, rcpt := range recipients {
if rcpt == "" {
continue
}
if err := c.Rcpt(rcpt); err != nil {
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
}
}
msg, err := buildMIME(from, to, ev.title(), textEmail(ev), htmlEmail(ev))
if err != nil {
return fmt.Errorf("smtp: build message: %w", err)
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("smtp: data: %w", err)
}
if _, err := w.Write(msg); err != nil {
return fmt.Errorf("smtp: write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp: close data: %w", err)
}
return c.Quit()
}
+167
View File
@@ -0,0 +1,167 @@
package notify
import (
"fmt"
"html"
"mime/multipart"
"net/textproto"
"strings"
"github.com/mrhid6/vantage/server/internal/models"
)
// App theme colors (mirrors web/tailwind.config.ts).
const (
colBg = "#0f1117"
colSurface = "#1a1d27"
colSurface2 = "#232635"
colBorder = "#2e3147"
colText = "#e8eaf0"
colTextMuted = "#9095a8"
colAccent = "#6366f1"
colSuccess = "#22c55e"
colDanger = "#ef4444"
)
// statusColor returns the accent color for a monitor status.
func statusColor(status string) string {
switch status {
case models.StatusUp:
return colSuccess
case models.StatusDown:
return colDanger
default:
return colTextMuted
}
}
// buildMIME assembles a multipart/alternative message (plain + HTML) with the
// standard email headers, ready to hand to the SMTP DATA command.
func buildMIME(from, to, subject, text, htmlBody string) ([]byte, error) {
var buf strings.Builder
w := multipart.NewWriter(&buf)
var head strings.Builder
head.WriteString("From: " + from + "\r\n")
head.WriteString("To: " + to + "\r\n")
head.WriteString("Subject: " + subject + "\r\n")
head.WriteString("MIME-Version: 1.0\r\n")
head.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n\r\n", w.Boundary()))
textPart, err := w.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/plain; charset=UTF-8"}})
if err != nil {
return nil, err
}
textPart.Write([]byte(text))
htmlPart, err := w.CreatePart(textproto.MIMEHeader{"Content-Type": {"text/html; charset=UTF-8"}})
if err != nil {
return nil, err
}
htmlPart.Write([]byte(htmlBody))
if err := w.Close(); err != nil {
return nil, err
}
return []byte(head.String() + buf.String()), nil
}
// htmlEmail renders the alert as a dark-themed HTML email matching the app.
func htmlEmail(ev Event) string {
accent := statusColor(ev.NewStatus)
label := "Recovered"
if ev.NewStatus == models.StatusDown {
label = "Down"
}
esc := html.EscapeString
row := func(k, v string) string {
if v == "" {
v = "—"
}
return fmt.Sprintf(
`<tr>`+
`<td style="padding:8px 0;color:%s;font-size:13px;width:120px;">%s</td>`+
`<td style="padding:8px 0;color:%s;font-size:13px;font-weight:500;">%s</td>`+
`</tr>`,
colTextMuted, k, colText, esc(v))
}
message := ""
if ev.Message != "" {
message = fmt.Sprintf(
`<p style="margin:0 0 20px;padding:12px 14px;background:%s;border:1px solid %s;border-radius:8px;color:%s;font-size:13px;">%s</p>`,
colSurface2, colBorder, colText, esc(ev.Message))
}
transition := esc(ev.OldStatus) + " → " + esc(ev.NewStatus)
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background:%s;">
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background:%s;padding:32px 0;">
<tr>
<td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="max-width:480px;background:%s;border:1px solid %s;border-radius:12px;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
<tr><td style="height:4px;background:%s;"></td></tr>
<tr>
<td style="padding:28px 28px 20px;">
<table role="presentation" cellpadding="0" cellspacing="0" style="margin-bottom:18px;">
<tr>
<td style="font-size:18px;font-weight:700;color:%s;">Vantage</td>
</tr>
</table>
<span style="display:inline-block;padding:4px 12px;border-radius:9999px;background:%s22;color:%s;font-size:12px;font-weight:600;letter-spacing:.02em;">%s</span>
<h1 style="margin:14px 0 6px;font-size:20px;font-weight:700;color:%s;">%s</h1>
<p style="margin:0 0 20px;color:%s;font-size:13px;">%s check</p>
%s
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="border-top:1px solid %s;">
%s
%s
%s
</table>
</td>
</tr>
<tr>
<td style="padding:16px 28px;border-top:1px solid %s;">
<p style="margin:0;color:%s;font-size:12px;">Sent by Vantage · self-hosted service monitoring</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`,
colBg,
colBg,
colSurface, colBorder,
accent,
colText,
accent, accent, label,
colText, esc(ev.MonitorName),
colTextMuted, esc(ev.Type),
message,
colBorder,
row("Status", transition),
row("Type", ev.Type),
row("Time", ev.Time.Format("2006-01-02 15:04:05 MST")),
colBorder,
colTextMuted,
)
}
// textEmail renders the plain-text fallback.
func textEmail(ev Event) string {
return strings.Join([]string{
ev.title(),
"",
"Monitor: " + ev.MonitorName,
"Type: " + ev.Type,
"Status: " + ev.OldStatus + " -> " + ev.NewStatus,
"Message: " + ev.Message,
"Time: " + ev.Time.Format("2006-01-02 15:04:05 MST"),
"",
"Sent by Vantage · self-hosted service monitoring",
}, "\r\n")
}
+50
View File
@@ -0,0 +1,50 @@
package services
import (
"context"
"log"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func LogEvent(eventType, actor, serverID, keyID, details string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
event := models.AuditEvent{
EventType: eventType,
Actor: actor,
ServerID: serverID,
KeyID: keyID,
Details: details,
CreatedAt: time.Now(),
}
if _, err := db.Col("audit_logs").InsertOne(ctx, event); err != nil {
log.Printf("audit log error: %v", err)
}
}
func ListAuditEvents(limit int64) ([]models.AuditEvent, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
opts := options.Find().
SetSort(bson.D{{Key: "created_at", Value: -1}}).
SetLimit(limit)
cursor, err := db.Col("audit_logs").Find(ctx, bson.M{}, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var events []models.AuditEvent
if err := cursor.All(ctx, &events); err != nil {
return nil, err
}
return events, nil
}
+100
View File
@@ -0,0 +1,100 @@
package services
import (
"errors"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/notify"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func ListChannels() ([]models.NotificationChannel, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("notification_channels").Find(ctx, bson.M{}, options.Find().SetSort(bson.M{"created_at": 1}))
if err != nil {
return nil, err
}
var out []models.NotificationChannel
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
func GetChannel(channelID string) (*models.NotificationChannel, error) {
ctx, cancel := monCtx()
defer cancel()
var ch models.NotificationChannel
err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID}).Decode(&ch)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
return &ch, nil
}
// GetChannels loads multiple channels by ID, skipping any not found.
func GetChannels(channelIDs []string) ([]models.NotificationChannel, error) {
if len(channelIDs) == 0 {
return nil, nil
}
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"channel_id": bson.M{"$in": channelIDs}})
if err != nil {
return nil, err
}
var out []models.NotificationChannel
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
func CreateChannel(ch *models.NotificationChannel) (*models.NotificationChannel, error) {
ctx, cancel := monCtx()
defer cancel()
ch.ChannelID = uuid.NewString()
ch.CreatedAt = time.Now()
if ch.Config == nil {
ch.Config = map[string]string{}
}
if _, err := db.Col("notification_channels").InsertOne(ctx, ch); err != nil {
return nil, err
}
return ch, nil
}
func UpdateChannel(channelID string, upd bson.M) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID}, bson.M{"$set": upd})
return err
}
func DeleteChannel(channelID string) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID})
return err
}
// TestChannel sends a synthetic alert to verify configuration.
func TestChannel(channelID string) error {
ch, err := GetChannel(channelID)
if err != nil {
return err
}
if ch == nil {
return errors.New("channel not found")
}
return notify.Test(*ch)
}
+250
View File
@@ -0,0 +1,250 @@
package services
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
func sessionHMACKey() ([]byte, error) {
// Reuse the AES key material as the HMAC secret. Distinct domain via prefix.
k, err := encryptionKey()
if err != nil {
return nil, err
}
mac := hmac.New(sha256.New, k)
mac.Write([]byte("vantage-console-session-v1"))
return mac.Sum(nil), nil
}
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
// SignSessionToken returns a signed, expiring token binding a session id.
func SignSessionToken(sessionID string, ttl time.Duration) (string, error) {
key, err := sessionHMACKey()
if err != nil {
return "", err
}
exp := time.Now().Add(ttl).Unix()
payload := fmt.Sprintf("%s.%d", b64([]byte(sessionID)), exp)
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payload))
return payload + "." + b64(mac.Sum(nil)), nil
}
// VerifySessionToken checks signature + expiry and returns the session id.
func VerifySessionToken(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", fmt.Errorf("malformed token")
}
payload := parts[0] + "." + parts[1]
key, err := sessionHMACKey()
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(payload))
want := mac.Sum(nil)
got, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil || !hmac.Equal(want, got) {
return "", fmt.Errorf("invalid signature")
}
exp, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return "", fmt.Errorf("invalid expiry")
}
if time.Now().Unix() > exp {
return "", fmt.Errorf("token expired")
}
sid, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", fmt.Errorf("invalid session id")
}
return string(sid), nil
}
type GuacParams struct {
Protocol string
Params map[string]string
}
func portOr(v, def int) string {
if v == 0 {
v = def
}
return strconv.Itoa(v)
}
// BuildGuacParams assembles the guacd connection parameter map for a protocol.
// privateKey/passphrase are the decrypted SSH private key and its optional
// passphrase (ssh only); rdpUser/rdpPass are used for rdp, and rdpPass carries
// the password for vnc. None of these values are persisted or logged by the caller.
func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass string) (*GuacParams, error) {
host := srv.IPAddress
switch protocol {
case "ssh":
p := map[string]string{
"hostname": host,
"port": portOr(srv.SSHPort, 22),
}
if sshUser == "" {
sshUser = "root"
}
p["username"] = sshUser
if privateKey != "" {
p["private-key"] = privateKey
}
if passphrase != "" {
p["passphrase"] = passphrase
}
return &GuacParams{Protocol: "ssh", Params: p}, nil
case "rdp":
return &GuacParams{Protocol: "rdp", Params: map[string]string{
"hostname": host,
"port": portOr(srv.RDPPort, 3389),
"username": rdpUser,
"password": rdpPass,
"security": "any",
"ignore-cert": "true",
}}, nil
case "vnc":
return &GuacParams{Protocol: "vnc", Params: map[string]string{
"hostname": host,
"port": "5900",
"password": rdpPass,
}}, nil
default:
return nil, fmt.Errorf("unsupported protocol %q", protocol)
}
}
func CreateConsoleSession(serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s := &models.ConsoleSession{
SessionID: uuid.NewString(),
ServerID: serverID,
Protocol: protocol,
KeyID: keyID,
User: user,
ClientIP: clientIP,
StartedAt: time.Now(),
}
if _, err := db.Col("console_sessions").InsertOne(ctx, s); err != nil {
return nil, err
}
return s, nil
}
func GetConsoleSession(sessionID string) (*models.ConsoleSession, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.ConsoleSession
if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID}).Decode(&s); err != nil {
return nil, err
}
return &s, nil
}
// StashConsoleRDPCreds encrypts and stores single-use RDP credentials on the
// session document. They are consumed (and cleared) when the tunnel opens.
func StashConsoleRDPCreds(sessionID, username, password string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
u, err := encryptString(username)
if err != nil {
return err
}
p, err := encryptString(password)
if err != nil {
return err
}
_, err = db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID},
bson.M{"$set": bson.M{"rdp_user_enc": u, "rdp_pass_enc": p}},
)
return err
}
// ConsumeConsoleRDPCreds decrypts and returns the stored RDP credentials, then
// clears them from the session document (single-use). Returns empty strings if
// none were stored.
func ConsumeConsoleRDPCreds(sessionID string) (username, password string, err error) {
s, err := GetConsoleSession(sessionID)
if err != nil {
return "", "", err
}
if s.RDPUserEnc == "" && s.RDPPassEnc == "" {
return "", "", nil
}
if s.RDPUserEnc != "" {
if username, err = decryptString(s.RDPUserEnc); err != nil {
return "", "", err
}
}
if s.RDPPassEnc != "" {
if password, err = decryptString(s.RDPPassEnc); err != nil {
return "", "", err
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _ = db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID},
bson.M{"$unset": bson.M{"rdp_user_enc": "", "rdp_pass_enc": ""}},
)
return username, password, nil
}
// SetConsoleSSHUser persists the SSH username to use on the session doc.
func SetConsoleSSHUser(sessionID, username string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID},
bson.M{"$set": bson.M{"ssh_username": username}})
return err
}
// ConsumeSessionToken atomically marks a session's one-time token as spent.
// It returns an error if the token was already consumed (replay) or the session
// does not exist, so the tunnel can be opened at most once per issued token.
func ConsumeSessionToken(sessionID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
res, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID, "token_consumed_at": nil},
bson.M{"$set": bson.M{"token_consumed_at": now}},
)
if err != nil {
return err
}
if res.MatchedCount == 0 {
return fmt.Errorf("session token already used")
}
return nil
}
func EndConsoleSession(sessionID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
_, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID, "ended_at": nil},
bson.M{"$set": bson.M{"ended_at": now}},
)
return err
}
+111
View File
@@ -0,0 +1,111 @@
package services
import (
"testing"
"time"
"github.com/mrhid6/vantage/server/internal/models"
)
func TestSessionTokenRoundTrip(t *testing.T) {
t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
tok, err := SignSessionToken("sess-123", time.Minute)
if err != nil {
t.Fatalf("sign: %v", err)
}
got, err := VerifySessionToken(tok)
if err != nil {
t.Fatalf("verify: %v", err)
}
if got != "sess-123" {
t.Fatalf("got %q want sess-123", got)
}
}
func TestSessionTokenExpired(t *testing.T) {
t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
tok, err := SignSessionToken("sess-123", -time.Second)
if err != nil {
t.Fatalf("sign: %v", err)
}
if _, err := VerifySessionToken(tok); err == nil {
t.Fatalf("expected expiry error, got nil")
}
}
func TestSessionTokenTampered(t *testing.T) {
t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
tok, _ := SignSessionToken("sess-123", time.Minute)
if _, err := VerifySessionToken(tok + "x"); err == nil {
t.Fatalf("expected signature error, got nil")
}
}
func TestBuildGuacParamsSSH(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22}
p, err := BuildGuacParams(srv, "ssh", "", "PRIVATE-KEY-DATA", "", "", "")
if err != nil {
t.Fatalf("err: %v", err)
}
if p.Protocol != "ssh" {
t.Fatalf("protocol %q", p.Protocol)
}
if p.Params["hostname"] != "10.0.0.5" || p.Params["port"] != "22" {
t.Fatalf("bad host/port: %+v", p.Params)
}
if p.Params["private-key"] != "PRIVATE-KEY-DATA" {
t.Fatalf("missing private-key")
}
if p.Params["username"] != "root" {
t.Fatalf("expected default username root, got %q", p.Params["username"])
}
}
func TestBuildGuacParamsRDP(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.9", RDPPort: 3389}
p, err := BuildGuacParams(srv, "rdp", "", "", "", "administrator", "s3cret")
if err != nil {
t.Fatalf("err: %v", err)
}
if p.Params["port"] != "3389" || p.Params["username"] != "administrator" || p.Params["password"] != "s3cret" {
t.Fatalf("bad rdp params: %+v", p.Params)
}
if p.Params["ignore-cert"] != "true" {
t.Fatalf("expected ignore-cert=true")
}
}
func TestBuildGuacParamsUnknownProtocol(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.9"}
if _, err := BuildGuacParams(srv, "telnet", "", "", "", "", ""); err == nil {
t.Fatalf("expected error for unknown protocol")
}
}
func TestBuildGuacParamsSSHPassphrase(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22}
p, err := BuildGuacParams(srv, "ssh", "deploy", "PK", "s3cret-phrase", "", "")
if err != nil {
t.Fatalf("err: %v", err)
}
if p.Params["username"] != "deploy" {
t.Fatalf("username %q", p.Params["username"])
}
if p.Params["passphrase"] != "s3cret-phrase" {
t.Fatalf("missing passphrase: %+v", p.Params)
}
}
func TestBuildGuacParamsVNC(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.7"}
p, err := BuildGuacParams(srv, "vnc", "", "", "", "", "vncpass")
if err != nil {
t.Fatalf("err: %v", err)
}
if p.Protocol != "vnc" || p.Params["hostname"] != "10.0.0.7" || p.Params["port"] != "5900" || p.Params["password"] != "vncpass" {
t.Fatalf("bad vnc params: %+v", p.Params)
}
}
+9 -2
View File
@@ -22,7 +22,9 @@ func encryptionKey() ([]byte, error) {
return key, nil
}
func encryptPrivateKey(plaintext string) (string, error) {
// encryptString encrypts a plaintext value with AES-256-GCM using the
// shared KEY_ENCRYPTION_KEY, returning hex(nonce + ciphertext).
func encryptString(plaintext string) (string, error) {
key, err := encryptionKey()
if err != nil {
return "", err
@@ -43,7 +45,8 @@ func encryptPrivateKey(plaintext string) (string, error) {
return hex.EncodeToString(sealed), nil
}
func decryptPrivateKey(ciphertextHex string) (string, error) {
// decryptString reverses encryptString.
func decryptString(ciphertextHex string) (string, error) {
key, err := encryptionKey()
if err != nil {
return "", err
@@ -70,3 +73,7 @@ func decryptPrivateKey(ciphertextHex string) (string, error) {
}
return string(plaintext), nil
}
func encryptPrivateKey(plaintext string) (string, error) { return encryptString(plaintext) }
func decryptPrivateKey(ciphertextHex string) (string, error) { return decryptString(ciphertextHex) }
+94
View File
@@ -0,0 +1,94 @@
package services
import (
"os"
"path/filepath"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// DefaultStepsDir returns the directory holding default step JSON files.
func DefaultStepsDir() string {
dir := os.Getenv("VANTAGE_DEFAULT_STEPS_DIR")
if dir == "" {
dir = filepath.Join("data", "default-steps")
}
_ = os.MkdirAll(dir, 0700)
return dir
}
// readDefaultStepFiles parses every *.json in the defaults dir into
// source=default library steps (with slug set). Non-json and invalid files are
// skipped silently; a slug is derived from the step name.
func readDefaultStepFiles() ([]models.WorkflowStep, error) {
matches, err := filepath.Glob(filepath.Join(DefaultStepsDir(), "*.json"))
if err != nil {
return nil, err
}
out := []models.WorkflowStep{}
for _, path := range matches {
b, err := os.ReadFile(path)
if err != nil {
continue
}
s, err := ParseStepDoc(b)
if err != nil {
continue
}
s.Source = "default"
s.Slug = Slugify(s.Name)
if s.Slug == "" {
continue
}
out = append(out, s)
}
return out, nil
}
// SeedDefaultSteps upserts default steps from disk keyed on {slug, source}.
// Re-sync overwrites default-step content; user steps are never touched.
func SeedDefaultSteps() (created, updated int, err error) {
steps, err := readDefaultStepFiles()
if err != nil {
return 0, 0, err
}
ctx, cancel := wfCtx()
defer cancel()
col := db.Col("workflow_steps")
for _, s := range steps {
filter := bson.M{"slug": s.Slug, "source": "default"}
set := bson.M{
"name": s.Name,
"description": s.Description,
"interpreter": s.Interpreter,
"script": s.Script,
"declared_outputs": s.DeclaredOutputs,
"declared_inputs": s.DeclaredInputs,
"secret_refs": s.SecretRefs,
"updated_at": time.Now(),
}
res, uerr := col.UpdateOne(ctx, filter, bson.M{
"$set": set,
"$setOnInsert": bson.M{
"step_id": uuid.New().String(),
"slug": s.Slug,
"source": "default",
"created_at": time.Now(),
},
}, options.UpdateOne().SetUpsert(true))
if uerr != nil {
return created, updated, uerr
}
if res.UpsertedCount > 0 {
created++
} else if res.ModifiedCount > 0 {
updated++
}
}
return created, updated, nil
}
+38
View File
@@ -0,0 +1,38 @@
package services
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultStepsDirEnv(t *testing.T) {
dir := filepath.Join(t.TempDir(), "ds")
t.Setenv("VANTAGE_DEFAULT_STEPS_DIR", dir)
got := DefaultStepsDir()
if got != dir {
t.Fatalf("got %q want %q", got, dir)
}
if _, err := os.Stat(dir); err != nil {
t.Fatalf("dir not created: %v", err)
}
}
func TestReadDefaultStepFiles(t *testing.T) {
dir := t.TempDir()
t.Setenv("VANTAGE_DEFAULT_STEPS_DIR", dir)
good := `{"kind":"vantage.step/v1","name":"Ping Host","interpreter":"bash","script":"ping -c1 x=1 >> $WORKFLOW_ENV"}`
os.WriteFile(filepath.Join(dir, "ping.json"), []byte(good), 0600)
os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignore me"), 0600)
steps, err := readDefaultStepFiles()
if err != nil {
t.Fatal(err)
}
if len(steps) != 1 {
t.Fatalf("want 1 step, got %d", len(steps))
}
if steps[0].Slug != "ping-host" || steps[0].Source != "default" {
t.Fatalf("bad seed step: %+v", steps[0])
}
}
+33 -2
View File
@@ -9,7 +9,7 @@ import (
"sync"
"github.com/google/uuid"
"github.com/mrhid6/keymanager/server/internal/grpc/pb"
"github.com/mrhid6/vantage/server/internal/grpc/pb"
)
type commandDispatcher struct {
@@ -62,6 +62,25 @@ func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) err
}
}
// DispatchRunStep pushes a RunStepCmd to a server's agent. Caller must have
// registered StepResults.Await(commandID) first.
func DispatchRunStep(serverID, commandID string, cmd *pb.RunStepCmd) error {
return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, RunStep: cmd})
}
// DispatchCleanupWorkspace tells a server's agent to remove a run's working
// directory. Best-effort and fire-and-forget: if the agent is gone the temp dir
// is reclaimed by the OS on reboot anyway.
func DispatchCleanupWorkspace(serverID, workspaceID string) {
if !Dispatcher.IsConnected(serverID) {
return
}
_ = Dispatcher.dispatch(serverID, &pb.ServerCommand{
CommandId: uuid.New().String(),
CleanupWorkspace: &pb.CleanupWorkspaceCmd{WorkspaceId: workspaceID},
})
}
// KeyGenParams carries all options for a generate-key command.
type KeyGenParams struct {
Label string
@@ -78,7 +97,7 @@ func GetLatestAgentVersion() (string, error) {
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/keymanager/releases?limit=20", giteaHost)
url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/vantage/releases?limit=20", giteaHost)
resp, err := http.Get(url) //nolint:gosec
if err != nil {
return "", fmt.Errorf("fetch releases: %w", err)
@@ -134,6 +153,18 @@ func DispatchUpdateAgent(serverID string) (string, error) {
return version, nil
}
// DispatchApplyUpdates sends an apply-updates command to the named server's agent.
func DispatchApplyUpdates(serverID string) error {
if !Dispatcher.IsConnected(serverID) {
return fmt.Errorf("agent is not connected to the command stream")
}
cmd := &pb.ServerCommand{
CommandId: uuid.New().String(),
ApplyUpdates: &pb.ApplyUpdatesCmd{},
}
return Dispatcher.dispatch(serverID, cmd)
}
// DispatchDeleteKey sends a delete-key command to the named server's agent.
// It is best-effort: if the agent is offline the local files will remain until next connection.
func DispatchDeleteKey(serverID, label string) {
+52
View File
@@ -0,0 +1,52 @@
package services
import (
"context"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/grpc/pb"
"go.mongodb.org/mongo-driver/v2/bson"
)
// StoreInventory upserts the latest inventory snapshot onto the server document.
// Metrics fields update every call; static fields only when r.IncludeStatic.
func StoreInventory(serverID string, r *pb.InventoryReport) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
set := bson.M{"inventory.metrics_at": now}
if r.CPU != nil {
set["inventory.cpu.usage_pct"] = r.CPU.UsagePct
set["inventory.cpu.load1"] = r.CPU.Load1
}
if r.Memory != nil {
set["inventory.memory.used_bytes"] = r.Memory.UsedBytes
}
set["inventory.swap_used_bytes"] = r.SwapUsed
if r.IncludeStatic {
set["inventory.static_at"] = now
set["inventory.swap_total_bytes"] = r.SwapTotal
set["inventory.kernel"] = r.Kernel
if r.CPU != nil {
set["inventory.cpu.model"] = r.CPU.Model
set["inventory.cpu.cores"] = r.CPU.Cores
}
if r.Memory != nil {
set["inventory.memory.total_bytes"] = r.Memory.TotalBytes
}
parts := make([]bson.M, 0, len(r.Partitions))
for _, p := range r.Partitions {
parts = append(parts, bson.M{
"device": p.Device, "mountpoint": p.Mountpoint, "fstype": p.Fstype,
"total_bytes": p.TotalBytes, "used_bytes": p.UsedBytes,
})
}
set["inventory.partitions"] = parts
}
_, err := db.Col("servers").UpdateOne(ctx, bson.M{"server_id": serverID}, bson.M{"$set": set})
return err
}
+27 -3
View File
@@ -9,8 +9,8 @@ import (
"time"
"github.com/google/uuid"
"github.com/mrhid6/keymanager/server/internal/db"
"github.com/mrhid6/keymanager/server/internal/models"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
@@ -33,9 +33,10 @@ func computeFingerprint(pubKey string) string {
func setKeyMeta(k *models.Key) {
k.HasPrivateKey = k.PrivateKeyEncrypted != ""
k.HasPassphrase = k.PassphraseEncrypted != ""
}
func CreateKey(label, publicKey, source, generatedByServerID, privateKey string) (*models.Key, error) {
func CreateKey(label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
key := &models.Key{
KeyID: uuid.NewString(),
Label: label,
@@ -52,6 +53,13 @@ func CreateKey(label, publicKey, source, generatedByServerID, privateKey string)
}
key.PrivateKeyEncrypted = enc
}
if passphrase != "" {
enc, err := encryptString(passphrase)
if err != nil {
return nil, fmt.Errorf("encrypt passphrase: %w", err)
}
key.PassphraseEncrypted = enc
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -90,6 +98,22 @@ func GetPrivateKey(keyID string) (string, error) {
return decryptPrivateKey(key.PrivateKeyEncrypted)
}
// GetPassphrase returns the decrypted passphrase for a key, or an empty string
// if the key has none stored.
func GetPassphrase(keyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key); err != nil {
return "", err
}
if key.PassphraseEncrypted == "" {
return "", nil
}
return decryptString(key.PassphraseEncrypted)
}
type KeyWithCount struct {
models.Key `bson:",inline"`
AssignedCount int `bson:"-" json:"assigned_count"`
+270
View File
@@ -0,0 +1,270 @@
package services
import (
"context"
"errors"
"log"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/checker"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/notify"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func monCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
// SpecFor maps a monitor onto a checker.Spec.
func SpecFor(m *models.Monitor) checker.Spec {
return checker.Spec{
Type: m.Type,
URL: m.Target.URL,
Host: m.Target.Host,
Port: m.Target.Port,
Method: m.Target.Method,
ExpectedStatus: m.Target.ExpectedStatus,
Keyword: m.Target.Keyword,
TLSWarnDays: m.Target.TLSWarnDays,
Insecure: m.Target.Insecure,
TimeoutSec: m.IntervalSec,
}
}
func ListMonitors() ([]models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("monitors").Find(ctx, bson.M{}, options.Find().SetSort(bson.M{"created_at": 1}))
if err != nil {
return nil, err
}
var out []models.Monitor
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
// ListMonitorsForRunner returns enabled monitors whose Runner matches runner.
func ListMonitorsForRunner(runner string) ([]models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("monitors").Find(ctx, bson.M{"runner": runner, "enabled": true})
if err != nil {
return nil, err
}
var out []models.Monitor
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
func GetMonitor(monitorID string) (*models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
var m models.Monitor
err := db.Col("monitors").FindOne(ctx, bson.M{"monitor_id": monitorID}).Decode(&m)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
func CreateMonitor(m *models.Monitor) (*models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
m.MonitorID = uuid.NewString()
m.CreatedAt = time.Now()
if m.IntervalSec <= 0 {
m.IntervalSec = 60
}
if m.Retries <= 0 {
m.Retries = 1
}
if m.Runner == "" {
m.Runner = models.RunnerServer
}
m.State = models.MonitorState{Status: models.StatusPending}
if _, err := db.Col("monitors").InsertOne(ctx, m); err != nil {
return nil, err
}
return m, nil
}
func UpdateMonitor(monitorID string, upd bson.M) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID}, bson.M{"$set": upd})
return err
}
func DeleteMonitor(monitorID string) error {
ctx, cancel := monCtx()
defer cancel()
if _, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID}); err != nil {
return err
}
db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID})
db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID})
return nil
}
func ListIncidents(monitorID string, limit int64) ([]models.Incident, error) {
ctx, cancel := monCtx()
defer cancel()
if limit <= 0 {
limit = 50
}
cur, err := db.Col("incidents").Find(ctx, bson.M{"monitor_id": monitorID},
options.Find().SetSort(bson.M{"started_at": -1}).SetLimit(limit))
if err != nil {
return nil, err
}
var out []models.Incident
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
// UptimeRollups returns hourly rollups for a monitor since the cutoff, oldest first.
func UptimeRollups(monitorID string, since time.Time) ([]models.Rollup, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("monitor_rollups").Find(ctx,
bson.M{"monitor_id": monitorID, "period_start": bson.M{"$gte": since}},
options.Find().SetSort(bson.M{"period_start": 1}))
if err != nil {
return nil, err
}
var out []models.Rollup
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
// IngestResult applies a check result to a monitor: updates state, opens/resolves
// incidents on up<->down transitions, rolls up the hourly bucket, and fires
// notifications on transition. Both the server scheduler and agent-reported
// results funnel through here.
func IngestResult(monitorID string, res checker.Result) error {
ctx, cancel := monCtx()
defer cancel()
m, err := GetMonitor(monitorID)
if err != nil || m == nil {
return err
}
now := time.Now()
prev := m.State.Status
retries := m.Retries
if retries < 1 {
retries = 1
}
newStatus := prev
fails := m.State.Fails
if res.Up {
fails = 0
newStatus = models.StatusUp
} else {
fails++
if fails >= retries {
newStatus = models.StatusDown
} else if prev == "" || prev == models.StatusPending {
newStatus = models.StatusPending
}
}
state := bson.M{
"state.status": newStatus,
"state.last_check_at": now,
"state.latency_ms": res.LatencyMs,
"state.message": res.Message,
"state.fails": fails,
}
if res.CertExpiry != nil {
state["state.cert_expiry_at"] = *res.CertExpiry
}
if _, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID}, bson.M{"$set": state}); err != nil {
return err
}
// Hourly rollup.
bucket := now.Truncate(time.Hour)
up := 0
if res.Up {
up = 1
}
db.Col("monitor_rollups").UpdateOne(ctx,
bson.M{"monitor_id": monitorID, "period_start": bucket},
bson.M{"$inc": bson.M{"checks": 1, "up_count": up, "sum_latency": int64(res.LatencyMs)}},
options.UpdateOne().SetUpsert(true))
// Transition handling.
if newStatus != prev {
switch newStatus {
case models.StatusDown:
inc := models.Incident{
IncidentID: uuid.NewString(),
MonitorID: monitorID,
StartedAt: now,
Cause: res.Message,
}
db.Col("incidents").InsertOne(ctx, inc)
notifyTransition(m, newStatus, res.Message)
case models.StatusUp:
if prev == models.StatusDown {
db.Col("incidents").UpdateOne(ctx,
bson.M{"monitor_id": monitorID, "resolved_at": nil},
bson.M{"$set": bson.M{"resolved_at": now}})
notifyTransition(m, newStatus, res.Message)
}
}
}
return nil
}
// notifyTransition dispatches notifications on an up<->down transition to each
// enabled channel bound to the monitor. Deliveries run in the background;
// failures are logged, not fatal.
func notifyTransition(m *models.Monitor, newStatus, message string) {
if len(m.ChannelIDs) == 0 {
return
}
channels, err := GetChannels(m.ChannelIDs)
if err != nil {
log.Printf("notify: load channels for %s: %v", m.MonitorID, err)
return
}
ev := notify.Event{
MonitorName: m.Name,
Type: m.Type,
OldStatus: m.State.Status,
NewStatus: newStatus,
Message: message,
Time: time.Now(),
}
for _, ch := range channels {
if !ch.Enabled {
continue
}
go func(c models.NotificationChannel) {
if err := notify.Dispatch(c, ev); err != nil {
log.Printf("notify: dispatch to %s (%s): %v", c.Name, c.Type, err)
}
}(ch)
}
_ = UpdateMonitor(m.MonitorID, bson.M{"state.last_notified_at": time.Now()})
}
+37
View File
@@ -0,0 +1,37 @@
package services
import (
"testing"
"github.com/mrhid6/vantage/server/internal/models"
)
func TestResolveInlineStep(t *testing.T) {
ref := models.WorkflowStepRef{
Order: 2,
OnFailure: "",
Inline: &models.WorkflowStep{
Name: "adhoc",
Interpreter: "bash",
Script: "echo hi",
SecretRefs: []string{"TOKEN"},
DeclaredInputs: []models.InputParam{
{Name: "REGION", Default: "eu"},
},
},
Inputs: map[string]string{"REGION": "us"},
}
rs := resolveInlineStep(ref)
if rs.Name != "adhoc" || rs.Script != "echo hi" || rs.Order != 2 {
t.Fatalf("bad resolve: %+v", rs)
}
if rs.OnFailure != "stop" {
t.Fatalf("want default on_failure=stop, got %q", rs.OnFailure)
}
if rs.Inputs["REGION"] != "us" {
t.Fatalf("want input override us, got %q", rs.Inputs["REGION"])
}
if len(rs.SecretRefs) != 1 || rs.SecretRefs[0] != "TOKEN" {
t.Fatalf("bad secret refs: %v", rs.SecretRefs)
}
}
+174
View File
@@ -0,0 +1,174 @@
package services
import (
"context"
"fmt"
"sort"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// EnsureSecretIndexes creates the unique compound index on (group, key).
func EnsureSecretIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := db.Col("secrets").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "group", Value: 1}, {Key: "key", Value: 1}},
Options: options.Index().SetUnique(true),
})
return err
}
// ListSecretGroups returns a summary of every group with its key count and
// most recent update time.
func ListSecretGroups() ([]models.GroupSummary, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pipeline := mongo.Pipeline{
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$group"},
{Key: "key_count", Value: bson.D{{Key: "$sum", Value: 1}}},
{Key: "updated_at", Value: bson.D{{Key: "$max", Value: "$updated_at"}}},
}}},
{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}},
}
cursor, err := db.Col("secrets").Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var rows []struct {
Group string `bson:"_id"`
KeyCount int `bson:"key_count"`
UpdatedAt time.Time `bson:"updated_at"`
}
if err := cursor.All(ctx, &rows); err != nil {
return nil, err
}
groups := make([]models.GroupSummary, 0, len(rows))
for _, r := range rows {
groups = append(groups, models.GroupSummary{
Group: r.Group,
KeyCount: r.KeyCount,
UpdatedAt: r.UpdatedAt,
})
}
return groups, nil
}
// GetSecretGroup returns the keys within a group, sorted by key name, without
// decrypted values.
func GetSecretGroup(group string) ([]models.Secret, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("secrets").Find(ctx, bson.M{"group": group},
options.Find().SetSort(bson.D{{Key: "key", Value: 1}}))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var docs []models.Secret
if err := cursor.All(ctx, &docs); err != nil {
return nil, err
}
return docs, nil
}
// GetSecretGroupDecrypted returns a flat map of key → plaintext value for a
// group. Used by the ESO read endpoint.
func GetSecretGroupDecrypted(group string) (map[string]string, error) {
docs, err := GetSecretGroup(group)
if err != nil {
return nil, err
}
result := make(map[string]string, len(docs))
for _, doc := range docs {
val, err := decryptString(doc.EncryptedValue)
if err != nil {
return nil, fmt.Errorf("decrypt %s/%s: %w", group, doc.Key, err)
}
result[doc.Key] = val
}
return result, nil
}
// RevealSecret returns the decrypted value of a single key.
func RevealSecret(group, key string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var doc models.Secret
err := db.Col("secrets").FindOne(ctx, bson.M{"group": group, "key": key}).Decode(&doc)
if err == mongo.ErrNoDocuments {
return "", fmt.Errorf("secret not found")
}
if err != nil {
return "", err
}
return decryptString(doc.EncryptedValue)
}
// UpsertSecrets encrypts and writes each key/value pair into the group.
func UpsertSecrets(group string, values map[string]string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for key, val := range values {
encrypted, err := encryptString(val)
if err != nil {
return fmt.Errorf("encrypt %s: %w", key, err)
}
_, err = db.Col("secrets").UpdateOne(ctx,
bson.M{"group": group, "key": key},
bson.M{"$set": bson.M{
"encrypted_value": encrypted,
"updated_at": time.Now(),
}},
options.UpdateOne().SetUpsert(true),
)
if err != nil {
return err
}
}
return nil
}
// SortedKeys returns the map keys sorted — handy for stable audit messages.
func SortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// DeleteSecret removes a single key from a group.
func DeleteSecret(group, key string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("secrets").DeleteOne(ctx, bson.M{"group": group, "key": key})
return err
}
// DeleteSecretGroup removes an entire group and all its keys.
func DeleteSecretGroup(group string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("secrets").DeleteMany(ctx, bson.M{"group": group})
return err
}
+131 -15
View File
@@ -6,11 +6,12 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/mrhid6/keymanager/server/internal/db"
"github.com/mrhid6/keymanager/server/internal/models"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -78,6 +79,25 @@ func GetServerByPreRegToken(token string) (*models.Server, error) {
return &s, nil
}
// OSTypeFromInfo derives a coarse os_type ("windows" or "linux") from the
// agent-reported os_info string, which is formatted "<GOOS> <GOARCH>".
// Anything that is not explicitly windows defaults to linux.
func OSTypeFromInfo(osInfo string) string {
if strings.HasPrefix(strings.ToLower(osInfo), "windows") {
return "windows"
}
return "linux"
}
// defaultConsoleFields returns the initial console configuration for a newly
// registered server based on its os_type.
func defaultConsoleFields(osType string) (protocols []string, sshPort, rdpPort int) {
if osType == "windows" {
return []string{"rdp"}, 22, 3389
}
return []string{"ssh"}, 22, 3389
}
func RegisterServer(serverID, preRegToken, hostname, ipAddress, osInfo string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -99,18 +119,31 @@ func RegisterServer(serverID, preRegToken, hostname, ipAddress, osInfo string) (
tokenHash := HashToken(agentToken)
now := time.Now()
osType := OSTypeFromInfo(osInfo)
protocols, sshPort, rdpPort := defaultConsoleFields(osType)
setFields := bson.M{
"hostname": hostname,
"ip_address": ipAddress,
"os_info": osInfo,
"os_type": osType,
"agent_token_hash": tokenHash,
"status": "active",
"last_seen": now,
"pre_reg_token": "",
"pre_reg_expires": nil,
}
if len(s.ConsoleProtocols) == 0 {
setFields["console_protocols"] = protocols
setFields["ssh_port"] = sshPort
setFields["rdp_port"] = rdpPort
}
_, err = db.Col("servers").UpdateOne(ctx,
bson.M{"server_id": serverID},
bson.M{"$set": bson.M{
"hostname": hostname,
"ip_address": ipAddress,
"os_info": osInfo,
"agent_token_hash": tokenHash,
"status": "active",
"last_seen": now,
"pre_reg_token": "",
"pre_reg_expires": nil,
}},
bson.M{
"$set": setFields,
},
)
if err != nil {
return "", err
@@ -134,6 +167,37 @@ func ValidateAgentToken(serverID, agentToken string) (*models.Server, error) {
return &s, nil
}
// BackfillConsoleConfig sets default console_protocols/ports for a server that
// predates the console feature (or was updated without re-registering). Servers
// register only once via a single-use pre_reg_token, so Register() never runs
// again to populate these fields — this runs on every sync as a cheap no-op
// once the fields are present.
func BackfillConsoleConfig(srv *models.Server) error {
if srv == nil || len(srv.ConsoleProtocols) > 0 {
return nil
}
osType := srv.OSType
if osType == "" {
osType = OSTypeFromInfo(srv.OSInfo)
}
protocols, sshPort, rdpPort := defaultConsoleFields(osType)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("servers").UpdateOne(ctx,
bson.M{"server_id": srv.ServerID, "console_protocols": bson.M{"$in": []interface{}{nil, bson.A{}}}},
bson.M{"$set": bson.M{
"os_type": osType,
"console_protocols": protocols,
"ssh_port": sshPort,
"rdp_port": rdpPort,
}},
)
return err
}
func UpdateServerLastSeen(serverID, agentVersion string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -141,7 +205,7 @@ func UpdateServerLastSeen(serverID, agentVersion string) error {
now := time.Now()
fields := bson.M{"last_seen": now, "status": "active"}
if agentVersion != "" {
fields["agent_version"] = agentVersion
fields["agent_version"] = strings.TrimPrefix(agentVersion, "v")
}
_, err := db.Col("servers").UpdateOne(ctx,
bson.M{"server_id": serverID},
@@ -181,12 +245,64 @@ func DeleteServer(serverID string) error {
return err
}
func MarkOfflineServers(threshold time.Duration) error {
func StoreAvailableUpdates(serverID string, pkgs []models.PackageUpdate) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
_, err := db.Col("servers").UpdateOne(ctx,
bson.M{"server_id": serverID},
bson.M{"$set": bson.M{
"available_updates": pkgs,
"updates_checked_at": now,
}},
)
return err
}
func MarkOfflineServers() error {
settings, _ := GetSettings()
thresholdMinutes := 5
if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 {
thresholdMinutes = settings.Alerts.OfflineThresholdMinutes
}
threshold := time.Duration(thresholdMinutes) * time.Minute
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cutoff := time.Now().Add(-threshold)
_, err := db.Col("servers").UpdateMany(ctx,
// Find servers about to transition to offline so we can alert on them.
cursor, err := db.Col("servers").Find(ctx, bson.M{
"status": "active",
"last_seen": bson.M{"$lt": cutoff},
})
if err != nil {
return err
}
defer cursor.Close(ctx)
var goingOffline []models.Server
if err := cursor.All(ctx, &goingOffline); err != nil {
return err
}
if len(goingOffline) == 0 {
return nil
}
for _, s := range goingOffline {
LogEvent("server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress))
if settings != nil && settings.Alerts.Enabled && settings.Alerts.WebhookURL != "" {
go SendOfflineWebhook(settings.Alerts.WebhookURL, s.Hostname, s.ServerID, s.IPAddress)
}
if settings != nil && settings.Email.Enabled {
go SendOfflineEmail(settings.Email, s.Hostname, s.ServerID, s.IPAddress)
}
}
_, err = db.Col("servers").UpdateMany(ctx,
bson.M{
"status": "active",
"last_seen": bson.M{"$lt": cutoff},
@@ -0,0 +1,18 @@
package services
import "testing"
func TestOSTypeFromInfo(t *testing.T) {
cases := map[string]string{
"windows amd64": "windows",
"linux amd64": "linux",
"linux arm64": "linux",
"": "linux",
"darwin arm64": "linux",
}
for in, want := range cases {
if got := OSTypeFromInfo(in); got != want {
t.Errorf("OSTypeFromInfo(%q) = %q, want %q", in, got, want)
}
}
}
+237
View File
@@ -0,0 +1,237 @@
package services
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"net/smtp"
"strings"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var defaultSettings = models.Settings{
Alerts: models.AlertSettings{
Enabled: false,
WebhookURL: "",
OfflineThresholdMinutes: 5,
},
Email: models.EmailSettings{
SMTPPort: 587,
},
}
func GetSettings() (*models.Settings, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.Settings
err := db.Col("settings").FindOne(ctx, bson.M{}).Decode(&s)
if err == mongo.ErrNoDocuments {
cp := defaultSettings
return &cp, nil
}
if err != nil {
return nil, err
}
s.Secrets.ReadTokenSet = s.Secrets.ReadTokenHash != ""
return &s, nil
}
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// RotateSecretsReadToken generates a new ESO read token, stores its SHA-256
// hash, and returns the plaintext token exactly once.
func RotateSecretsReadToken() (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := hex.EncodeToString(raw)
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{},
bson.M{"$set": bson.M{
"secrets.read_token_hash": hashToken(token),
"secrets.rotated_at": time.Now(),
}},
options.UpdateOne().SetUpsert(true),
)
if err != nil {
return "", err
}
return token, nil
}
// VerifySecretsReadToken reports whether the supplied token matches the stored
// hash, using a constant-time comparison.
func VerifySecretsReadToken(token string) bool {
if token == "" {
return false
}
s, err := GetSettings()
if err != nil || s.Secrets.ReadTokenHash == "" {
return false
}
expected, err := hex.DecodeString(s.Secrets.ReadTokenHash)
if err != nil {
return false
}
got := sha256.Sum256([]byte(token))
return subtle.ConstantTimeCompare(expected, got[:]) == 1
}
func SaveSettings(alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if alerts.OfflineThresholdMinutes <= 0 {
alerts.OfflineThresholdMinutes = 5
}
if email.SMTPPort <= 0 {
email.SMTPPort = 587
}
set := bson.M{"alerts": alerts, "email": email}
if retentionDays != nil {
set["workflow_log_retention_days"] = *retentionDays
}
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{},
bson.M{"$set": set},
options.UpdateOne().SetUpsert(true),
)
return err
}
// GetWorkflowLogRetentionDays returns the log retention in days: 30 when unset,
// 0 for keep-forever, or the configured value.
func GetWorkflowLogRetentionDays() (int, error) {
s, err := GetSettings()
if err != nil {
return 30, err
}
if s.WorkflowLogRetentionDays == nil {
return 30, nil
}
return *s.WorkflowLogRetentionDays, nil
}
func SendOfflineWebhook(webhookURL, hostname, serverID, ipAddress string) {
payload := map[string]any{
"event": "server.offline",
"hostname": hostname,
"server_id": serverID,
"ip_address": ipAddress,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"message": fmt.Sprintf("Server %s (%s) has gone offline", hostname, ipAddress),
}
body, err := json.Marshal(payload)
if err != nil {
log.Printf("webhook marshal error: %v", err)
return
}
resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("webhook delivery error for %s: %v", hostname, err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
log.Printf("webhook returned %d for %s", resp.StatusCode, hostname)
}
}
func SendOfflineEmail(cfg models.EmailSettings, hostname, serverID, ipAddress string) {
if !cfg.Enabled || cfg.SMTPHost == "" || len(cfg.ToAddrs) == 0 {
return
}
subject := fmt.Sprintf("Vantage Alert: %s is offline", hostname)
bodyText := fmt.Sprintf(
"Server %s (%s) has gone offline.\r\n\r\nServer ID: %s\r\nTimestamp: %s\r\n",
hostname, ipAddress, serverID, time.Now().UTC().Format(time.RFC3339),
)
msg := []byte(fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
cfg.FromAddr,
strings.Join(cfg.ToAddrs, ", "),
subject,
bodyText,
))
addr := fmt.Sprintf("%s:%d", cfg.SMTPHost, cfg.SMTPPort)
var auth smtp.Auth
if cfg.Username != "" {
auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.SMTPHost)
}
var sendErr error
if cfg.UseTLS {
sendErr = sendMailTLS(addr, cfg.SMTPHost, auth, cfg.FromAddr, cfg.ToAddrs, msg)
} else {
sendErr = smtp.SendMail(addr, auth, cfg.FromAddr, cfg.ToAddrs, msg)
}
if sendErr != nil {
log.Printf("email alert error for %s: %v", hostname, sendErr)
}
}
// sendMailTLS dials with implicit TLS (port 465) instead of STARTTLS.
func sendMailTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
if err != nil {
return fmt.Errorf("tls dial: %w", err)
}
c, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
defer c.Close()
if auth != nil {
if err := c.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := c.Mail(from); err != nil {
return err
}
for _, rcpt := range to {
if err := c.Rcpt(strings.TrimSpace(rcpt)); err != nil {
return err
}
}
w, err := c.Data()
if err != nil {
return err
}
if _, err := w.Write(msg); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return c.Quit()
}
+86
View File
@@ -0,0 +1,86 @@
package services
import (
"encoding/json"
"fmt"
"github.com/mrhid6/vantage/server/internal/models"
)
const StepDocKind = "vantage.step/v1"
// StepDoc is the portable, id-free representation of a step.
type StepDoc struct {
Kind string `json:"kind"`
Name string `json:"name"`
Description string `json:"description"`
Interpreter string `json:"interpreter"`
Script string `json:"script"`
DeclaredOutputs []string `json:"declared_outputs"`
DeclaredInputs []models.InputParam `json:"declared_inputs"`
SecretRefs []string `json:"secret_refs"`
}
// ExportStepDoc builds a portable doc from a library step (ids/source stripped).
func ExportStepDoc(s models.WorkflowStep) StepDoc {
return StepDoc{
Kind: StepDocKind,
Name: s.Name,
Description: s.Description,
Interpreter: s.Interpreter,
Script: s.Script,
DeclaredOutputs: s.DeclaredOutputs,
DeclaredInputs: s.DeclaredInputs,
SecretRefs: s.SecretRefs,
}
}
// ParseStepDoc validates a v1 doc and returns a normalized (id-free) step with
// declared_outputs recomputed from the script.
func ParseStepDoc(b []byte) (models.WorkflowStep, error) {
var d StepDoc
if err := json.Unmarshal(b, &d); err != nil {
return models.WorkflowStep{}, fmt.Errorf("invalid step JSON: %w", err)
}
if d.Kind != StepDocKind {
return models.WorkflowStep{}, fmt.Errorf("unsupported kind %q (want %q)", d.Kind, StepDocKind)
}
if d.Name == "" || d.Interpreter == "" {
return models.WorkflowStep{}, fmt.Errorf("step name and interpreter are required")
}
if d.SecretRefs == nil {
d.SecretRefs = []string{}
}
if d.DeclaredInputs == nil {
d.DeclaredInputs = []models.InputParam{}
}
return models.WorkflowStep{
Name: d.Name,
Description: d.Description,
Interpreter: d.Interpreter,
Script: d.Script,
DeclaredOutputs: DeriveOutputs(d.Script),
DeclaredInputs: d.DeclaredInputs,
SecretRefs: d.SecretRefs,
}, nil
}
// ImportStepToLibrary parses a doc and persists it as a new user library step.
func ImportStepToLibrary(b []byte) (*models.WorkflowStep, error) {
s, err := ParseStepDoc(b)
if err != nil {
return nil, err
}
return CreateStep(s)
}
// ExportStep loads a library step and marshals it to a portable doc.
func ExportStep(stepID string) ([]byte, error) {
ctx, cancel := wfCtx()
defer cancel()
s, err := getStep(ctx, stepID)
if err != nil {
return nil, err
}
return json.MarshalIndent(ExportStepDoc(*s), "", " ")
}
+60
View File
@@ -0,0 +1,60 @@
package services
import (
"encoding/json"
"testing"
"github.com/mrhid6/vantage/server/internal/models"
)
func mkStep() models.WorkflowStep {
return models.WorkflowStep{
StepID: "should-not-export", Source: "default", Name: "Restart",
Interpreter: "bash", Script: "echo x=1 >> $WORKFLOW_ENV",
SecretRefs: []string{"TOK"},
}
}
func TestParseStepDocValid(t *testing.T) {
raw := `{"kind":"vantage.step/v1","name":"Restart","interpreter":"bash",
"script":"echo x=1 >> $WORKFLOW_ENV","declared_outputs":["stale"],
"declared_inputs":[{"name":"A","default":"1"}],"secret_refs":["TOK"]}`
s, err := ParseStepDoc([]byte(raw))
if err != nil {
t.Fatal(err)
}
if s.Name != "Restart" || s.Interpreter != "bash" {
t.Fatalf("bad parse: %+v", s)
}
// declared_outputs recomputed from script, ignoring the file's ["stale"].
if len(s.DeclaredOutputs) != 1 || s.DeclaredOutputs[0] != "x" {
t.Fatalf("outputs should be derived, got %v", s.DeclaredOutputs)
}
if s.StepID != "" || s.Source != "" {
t.Fatalf("parse must not set id/source")
}
}
func TestParseStepDocBadKind(t *testing.T) {
if _, err := ParseStepDoc([]byte(`{"kind":"nope","name":"x"}`)); err == nil {
t.Fatal("want error for bad kind")
}
}
func TestParseStepDocBadJSON(t *testing.T) {
if _, err := ParseStepDoc([]byte(`{`)); err == nil {
t.Fatal("want error for bad json")
}
}
func TestExportStepDocRoundTrip(t *testing.T) {
doc := ExportStepDoc(mkStep())
b, _ := json.Marshal(doc)
s, err := ParseStepDoc(b)
if err != nil {
t.Fatal(err)
}
if s.Name != "Restart" || s.Interpreter != "bash" {
t.Fatalf("round trip lost data: %+v", s)
}
}
+217
View File
@@ -0,0 +1,217 @@
package services
import (
"bytes"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
)
// WorkflowLogDir returns the base directory for workflow step logs, creating it.
func WorkflowLogDir() string {
dir := os.Getenv("VANTAGE_WORKFLOW_LOG_DIR")
if dir == "" {
dir = filepath.Join("data", "workflow-logs")
}
_ = os.MkdirAll(dir, 0700)
return dir
}
// ServerRunLogPath is the per-server-run log file path.
func ServerRunLogPath(runID, serverID string) string {
return filepath.Join(WorkflowLogDir(), runID, serverID+".log")
}
// logTS is the UTC timestamp prefix stamped on every log line. Stored in UTC
// (RFC3339, millisecond precision); the UI renders it in the viewer's timezone.
func logTS() string {
return time.Now().UTC().Format("2006-01-02T15:04:05.000") + "Z"
}
// AppendMarker writes a timestamped event line to the server-run log and returns
// the byte offset at which the write began (used as a step's log_offset).
func AppendMarker(runID, serverID, text string) (int64, error) {
path := ServerRunLogPath(runID, serverID)
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return 0, err
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return 0, err
}
defer f.Close()
off, _ := f.Seek(0, 2) // current end = offset before write
if _, err := f.WriteString("[" + logTS() + "] " + text + "\n"); err != nil {
return off, err
}
return off, nil
}
// ---- streamed chunk writer, boundary-safe secret masking ----
type stepLogWriter struct {
mu sync.Mutex
f *os.File
carry []byte // bytes of an as-yet-unterminated line
secrets []string
}
type stepLogRegistry struct {
mu sync.Mutex
writers map[string]*stepLogWriter
}
var StepLogs = &stepLogRegistry{writers: make(map[string]*stepLogWriter)}
// Open opens (append) the server-run file for a step's streamed chunks.
func (r *stepLogRegistry) Open(commandID, path string, secrets []string) error {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
return err
}
w := &stepLogWriter{f: f, secrets: secrets}
r.mu.Lock()
r.writers[commandID] = w
r.mu.Unlock()
return nil
}
func (r *stepLogRegistry) get(commandID string) *stepLogWriter {
r.mu.Lock()
defer r.mu.Unlock()
return r.writers[commandID]
}
// Append buffers chunks into whole lines, then writes each complete line with a
// UTC timestamp prefix and secret masking applied. Buffering by line means a
// secret split across a chunk boundary is always masked (the whole line is
// assembled first) and every line carries its own timestamp.
func (r *stepLogRegistry) Append(commandID string, data []byte) {
w := r.get(commandID)
if w == nil {
return
}
w.mu.Lock()
defer w.mu.Unlock()
buf := append(w.carry, data...)
for {
i := bytes.IndexByte(buf, '\n')
if i < 0 {
break
}
w.writeLine(buf[:i])
buf = buf[i+1:]
}
w.carry = append([]byte{}, buf...)
}
// writeLine emits one masked, timestamped log line. Caller holds w.mu.
func (w *stepLogWriter) writeLine(line []byte) {
masked := maskBytes(line, w.secrets)
_, _ = w.f.WriteString("[" + logTS() + "] ")
_, _ = w.f.Write(masked)
_, _ = w.f.WriteString("\n")
}
// Close flushes any trailing partial line and closes the file.
func (r *stepLogRegistry) Close(commandID string) {
r.mu.Lock()
w := r.writers[commandID]
delete(r.writers, commandID)
r.mu.Unlock()
if w == nil {
return
}
w.mu.Lock()
defer w.mu.Unlock()
if len(w.carry) > 0 {
w.writeLine(w.carry)
w.carry = nil
}
_ = w.f.Close()
}
func maskBytes(b []byte, secrets []string) []byte {
s := string(b)
for _, v := range secrets {
if v == "" {
continue
}
s = strings.ReplaceAll(s, v, "***")
}
return []byte(s)
}
// ---- retention sweeper ----
// StartLogSweeper sweeps expired run-log dirs hourly (and once now).
func StartLogSweeper() {
go func() {
sweepLogs()
t := time.NewTicker(time.Hour)
defer t.Stop()
for range t.C {
sweepLogs()
}
}()
}
func sweepLogs() {
days := retentionDays()
if days <= 0 {
return
}
cutoff := time.Now().AddDate(0, 0, -days)
base := WorkflowLogDir()
entries, err := os.ReadDir(base)
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() {
continue
}
runID := e.Name()
dir := filepath.Join(base, runID)
if runExpired(runID, dir, cutoff) {
_ = os.RemoveAll(dir)
}
}
}
// runExpired is true when the run finished before cutoff (falling back to dir
// mtime when the run doc is gone).
func runExpired(runID, dir string, cutoff time.Time) bool {
ctx, cancel := wfCtx()
defer cancel()
var run struct {
FinishedAt *time.Time `bson:"finished_at"`
}
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run)
if err == nil {
if run.FinishedAt == nil {
return false // still running / never finished — keep
}
return run.FinishedAt.Before(cutoff)
}
// run doc gone: use dir mtime
if fi, e := os.Stat(dir); e == nil {
return fi.ModTime().Before(cutoff)
}
return false
}
func retentionDays() int {
if v, err := GetWorkflowLogRetentionDays(); err == nil {
return v
}
return 30
}
+49
View File
@@ -0,0 +1,49 @@
package services
import (
"sync"
"github.com/mrhid6/vantage/server/internal/grpc/pb"
)
type stepResultRegistry struct {
mu sync.Mutex
pending map[string]chan *pb.StepResult
}
// StepResults correlates agent StepResult replies back to the workflow runner
// goroutine that dispatched the matching RunStepCmd, keyed by command_id.
var StepResults = &stepResultRegistry{pending: make(map[string]chan *pb.StepResult)}
// Await registers interest in a command's result BEFORE the command is
// dispatched, and returns a buffered channel that receives the single result.
func (r *stepResultRegistry) Await(commandID string) <-chan *pb.StepResult {
ch := make(chan *pb.StepResult, 1)
r.mu.Lock()
r.pending[commandID] = ch
r.mu.Unlock()
return ch
}
// Cancel removes a pending waiter (call on timeout to avoid leaks).
func (r *stepResultRegistry) Cancel(commandID string) {
r.mu.Lock()
delete(r.pending, commandID)
r.mu.Unlock()
}
// Deliver routes an incoming StepResult to its waiter, if any.
func (r *stepResultRegistry) Deliver(res *pb.StepResult) {
if res == nil {
return
}
r.mu.Lock()
ch, ok := r.pending[res.CommandId]
if ok {
delete(r.pending, res.CommandId)
}
r.mu.Unlock()
if ok {
ch <- res
}
}
+44
View File
@@ -0,0 +1,44 @@
package services
import (
"regexp"
"strings"
)
// keyAssign matches an env-var assignment target: KEY= (captures KEY).
var keyAssign = regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)=`)
// DeriveOutputs scans a step script and returns the output keys it writes to
// $WORKFLOW_ENV. Best-effort: only lines that reference WORKFLOW_ENV are
// considered. Deduplicated, first-seen order preserved.
func DeriveOutputs(script string) []string {
out := []string{}
seen := map[string]bool{}
for _, line := range strings.Split(script, "\n") {
if !strings.Contains(line, "WORKFLOW_ENV") {
continue
}
for _, m := range keyAssign.FindAllStringSubmatch(line, -1) {
key := m[1]
// Skip the sentinel itself (e.g. "WORKFLOW_ENV=..." assignments).
if key == "WORKFLOW_ENV" || key == "env" {
continue
}
if seen[key] {
continue
}
seen[key] = true
out = append(out, key)
}
}
return out
}
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
// Slugify converts a step name into a stable kebab-case slug.
func Slugify(name string) string {
s := strings.ToLower(name)
s = slugStrip.ReplaceAllString(s, "-")
return strings.Trim(s, "-")
}
+45
View File
@@ -0,0 +1,45 @@
package services
import (
"reflect"
"testing"
)
func TestDeriveOutputs(t *testing.T) {
script := `#!/bin/bash
echo "test=123" >> $WORKFLOW_ENV
echo "other=hi" >> "$WORKFLOW_ENV"
printf 'third=1\n' >> $WORKFLOW_ENV
echo "test=456" >> $WORKFLOW_ENV
echo "ignored=nope"
NORMAL=assignment
`
got := DeriveOutputs(script)
want := []string{"test", "other", "third"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v want %v", got, want)
}
}
func TestDeriveOutputsPowershell(t *testing.T) {
script := `"result=ok" >> $env:WORKFLOW_ENV
Add-Content $env:WORKFLOW_ENV "count=5"`
got := DeriveOutputs(script)
want := []string{"result", "count"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v want %v", got, want)
}
}
func TestDeriveOutputsNone(t *testing.T) {
got := DeriveOutputs("echo hello\nNOPE=1")
if len(got) != 0 {
t.Fatalf("got %v want empty", got)
}
}
func TestSlugify(t *testing.T) {
if got := Slugify("Restart NGINX Service!"); got != "restart-nginx-service" {
t.Fatalf("got %q", got)
}
}
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"time"
"github.com/mrhid6/keymanager/server/internal/db"
"github.com/mrhid6/keymanager/server/internal/models"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
)
+19
View File
@@ -0,0 +1,19 @@
package services
import (
"fmt"
"github.com/mrhid6/vantage/server/internal/models"
)
// ValidateWorkflow checks each step ref sets exactly one of step_id / inline.
func ValidateWorkflow(w models.Workflow) error {
for i, ref := range w.Steps {
hasLib := ref.StepID != ""
hasInline := ref.Inline != nil
if hasLib == hasInline {
return fmt.Errorf("step %d: exactly one of step_id or inline must be set", i)
}
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package services
import (
"testing"
"github.com/mrhid6/vantage/server/internal/models"
)
func TestValidateWorkflow(t *testing.T) {
inline := &models.WorkflowStep{Name: "x", Interpreter: "bash", Script: "echo hi"}
cases := []struct {
name string
ref models.WorkflowStepRef
wantErr bool
}{
{"library only", models.WorkflowStepRef{StepID: "abc"}, false},
{"inline only", models.WorkflowStepRef{Inline: inline}, false},
{"both set", models.WorkflowStepRef{StepID: "abc", Inline: inline}, true},
{"neither set", models.WorkflowStepRef{}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateWorkflow(models.Workflow{Steps: []models.WorkflowStepRef{tc.ref}})
if (err != nil) != tc.wantErr {
t.Fatalf("got err=%v want wantErr=%v", err, tc.wantErr)
}
})
}
}
+505
View File
@@ -0,0 +1,505 @@
package services
import (
"fmt"
"os"
"strings"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/grpc/pb"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
const stepDispatchGrace = 15 * time.Second
// TriggerWorkflow snapshots the workflow, creates a run doc, and starts a
// background goroutine per target server (parallel fan-out). Returns run_id.
func TriggerWorkflow(workflowID, actor string) (string, error) {
wf, err := GetWorkflow(workflowID)
if err != nil {
return "", err
}
if len(wf.TargetServerIDs) == 0 {
return "", fmt.Errorf("workflow has no target servers")
}
if len(wf.Steps) == 0 {
return "", fmt.Errorf("workflow has no steps")
}
// Reject a concurrent run of the same workflow.
ctx, cancel := wfCtx()
running := db.Col("workflow_runs").FindOne(ctx, bson.M{"workflow_id": workflowID, "status": "running"})
cancel()
if running.Err() == nil {
return "", fmt.Errorf("workflow already has a run in progress")
}
resolved, err := resolveSteps(wf)
if err != nil {
return "", err
}
run := models.WorkflowRun{
RunID: uuid.New().String(),
WorkflowID: workflowID,
Name: wf.Name,
Steps: resolved,
Status: "running",
TriggeredBy: actor,
StartedAt: time.Now(),
ServerRuns: make([]models.ServerRun, 0, len(wf.TargetServerIDs)),
}
for _, sid := range wf.TargetServerIDs {
hostname := sid
if s, e := GetServer(sid); e == nil {
hostname = s.Hostname
}
sr := models.ServerRun{ServerID: sid, Hostname: hostname, Status: "queued", RunEnv: map[string]string{}}
for _, rs := range resolved {
sr.Steps = append(sr.Steps, models.StepRun{Order: rs.Order, Name: rs.Name, Status: "queued", OutputEnv: map[string]string{}})
}
run.ServerRuns = append(run.ServerRuns, sr)
}
ictx, icancel := wfCtx()
defer icancel()
if _, err := db.Col("workflow_runs").InsertOne(ictx, run); err != nil {
return "", err
}
go executeRun(run.RunID)
return run.RunID, nil
}
// resolveSteps freezes each workflow step ref into a ResolvedStep by loading the
// library step and applying overrides.
func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) {
ctx, cancel := wfCtx()
defer cancel()
out := make([]models.ResolvedStep, 0, len(wf.Steps))
for _, ref := range wf.Steps {
if ref.Inline != nil {
out = append(out, resolveInlineStep(ref))
continue
}
lib, err := getStep(ctx, ref.StepID)
if err != nil {
return nil, err
}
inputs := map[string]string{}
for _, p := range lib.DeclaredInputs {
if ref.Inputs != nil {
if v, ok := ref.Inputs[p.Name]; ok {
inputs[p.Name] = v
continue
}
}
inputs[p.Name] = p.Default
}
rs := models.ResolvedStep{
Order: ref.Order,
Name: lib.Name,
Interpreter: lib.Interpreter,
Script: lib.Script,
SecretRefs: lib.SecretRefs,
OnFailure: ref.OnFailure,
MaxRetries: ref.MaxRetries,
Inputs: inputs,
}
if ref.Overrides != nil {
if ref.Overrides.Script != nil {
rs.Script = *ref.Overrides.Script
}
if ref.Overrides.SecretRefs != nil {
rs.SecretRefs = ref.Overrides.SecretRefs
}
}
if rs.OnFailure == "" {
rs.OnFailure = "stop"
}
out = append(out, rs)
}
return out, nil
}
// resolveInlineStep freezes an ad-hoc (inline) step ref into a ResolvedStep.
func resolveInlineStep(ref models.WorkflowStepRef) models.ResolvedStep {
in := ref.Inline
inputs := map[string]string{}
for _, p := range in.DeclaredInputs {
if ref.Inputs != nil {
if v, ok := ref.Inputs[p.Name]; ok {
inputs[p.Name] = v
continue
}
}
inputs[p.Name] = p.Default
}
onFailure := ref.OnFailure
if onFailure == "" {
onFailure = "stop"
}
return models.ResolvedStep{
Order: ref.Order,
Name: in.Name,
Interpreter: in.Interpreter,
Script: in.Script,
SecretRefs: in.SecretRefs,
OnFailure: onFailure,
MaxRetries: ref.MaxRetries,
Inputs: inputs,
}
}
// executeRun fans out one goroutine per server run and waits for all to finish.
func executeRun(runID string) {
run, err := GetRun(runID)
if err != nil {
return
}
done := make(chan int, len(run.ServerRuns))
for i := range run.ServerRuns {
go func(idx int) {
runServer(runID, idx, run.Steps, run.ServerRuns[idx].ServerID)
done <- idx
}(i)
}
for range run.ServerRuns {
<-done
}
// Aggregate status.
final, _ := GetRun(runID)
status := "success"
for _, sr := range final.ServerRuns {
if sr.Status == "failed" {
status = "failed"
}
}
now := time.Now()
ctx, cancel := wfCtx()
defer cancel()
_, _ = db.Col("workflow_runs").UpdateOne(ctx, bson.M{"run_id": runID, "status": "running"},
bson.M{"$set": bson.M{"status": status, "finished_at": now}})
}
// runServer executes the resolved steps sequentially on one server, threading
// output env forward and applying per-step failure policy.
func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID string) {
now := time.Now()
setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "running", "server_runs.$.started_at": now})
if !Dispatcher.IsConnected(serverID) {
fin := time.Now()
_, _ = AppendMarker(runID, serverID, "agent not connected — server skipped")
setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "skipped", "server_runs.$.finished_at": fin})
return
}
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("run started on %s — %d step(s), workspace vantage-run-%s", serverID, len(steps), runID))
runEnv := map[string]string{}
allSecrets := map[string]string{}
serverFailed := false
for i, step := range steps {
startStep(runID, serverID, i, "running")
stepStart := time.Now()
var res *pb.StepResult
attempts := 0
maxAttempts := 1
if step.OnFailure == "retry" {
maxAttempts = step.MaxRetries + 1
}
// Merge secrets into command env (kept out of persisted logs).
secretVals := resolveSecrets(step.SecretRefs)
for k, v := range secretVals {
allSecrets[k] = v
}
// Input values may template earlier step outputs and secrets, e.g.
// URL="http://example.com/$VersionNumber". Expand against runEnv (outputs
// threaded from prior steps) and this step's secrets before dispatch.
subst := map[string]string{}
for k, v := range runEnv {
subst[k] = v
}
for k, v := range secretVals {
subst[k] = v
}
cmdEnv := map[string]string{}
for k, v := range step.Inputs {
cmdEnv[k] = expandVars(v, subst)
}
for k, v := range runEnv {
cmdEnv[k] = v
}
for k, v := range secretVals {
cmdEnv[k] = v
}
// Write the step marker to the server-run log and remember the offset so
// the UI can slice this step's output later.
marker := fmt.Sprintf("===== step %d/%d: %s (%s) =====", step.Order+1, len(steps), step.Name, step.Interpreter)
offset, _ := AppendMarker(runID, serverID, marker)
logPath := ServerRunLogPath(runID, serverID)
secretsSlice := secretValues(secretVals)
commandID := uuid.New().String()
for attempts < maxAttempts {
attempts++
if attempts > 1 {
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("retry %d/%d after failure", attempts-1, maxAttempts-1))
}
// Open a fresh writer per attempt; the agent's eof closes it, and the
// defensive Close below covers a missing result.
_ = StepLogs.Open(commandID, logPath, secretsSlice)
res = dispatchAndWait(serverID, commandID, &pb.RunStepCmd{
Interpreter: step.Interpreter,
Script: step.Script,
Env: cmdEnv,
TimeoutSeconds: 0,
WorkspaceId: runID,
})
StepLogs.Close(commandID) // idempotent; no-op if eof already closed it
if res != nil && res.ExitCode == 0 {
break
}
}
exit := 1
outEnv := map[string]string{} // masked copy, safe to persist
if res != nil {
exit = res.ExitCode
for k, v := range res.OutputEnv {
runEnv[k] = v // real, unmasked value threads forward to later steps
outEnv[k] = maskSecrets(v, allSecrets)
}
} else {
_, _ = AppendMarker(runID, serverID, "agent did not return a result")
}
status := "success"
if exit != 0 {
status = "failed"
}
finishStep(runID, serverID, i, status, attempts, exit, offset, outEnv)
dur := time.Since(stepStart).Round(time.Millisecond)
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("step %d/%d %s — exit %d, %d attempt(s), %s",
step.Order+1, len(steps), status, exit, attempts, dur))
if exit != 0 {
switch step.OnFailure {
case "continue":
_, _ = AppendMarker(runID, serverID, "on_failure=continue — proceeding to next step")
default: // "stop" or exhausted "retry"
serverFailed = true
}
if serverFailed {
_, _ = AppendMarker(runID, serverID, "stopping run — remaining steps skipped")
markRemainingSkipped(runID, serverID, i+1)
break
}
}
}
// Tell the agent to remove the run's working directory now that its steps are
// done (success or failure). Best-effort; the OS reclaims temp dirs anyway.
DispatchCleanupWorkspace(serverID, runID)
fin := time.Now()
status := "success"
if serverFailed {
status = "failed"
}
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("run %s in %s — workspace removed",
status, fin.Sub(now).Round(time.Millisecond)))
// Persist only a masked copy of runEnv; the real (unmasked) runEnv was already
// used above to build cmdEnv for each step and must never be written to the DB.
maskedRunEnv := make(map[string]string, len(runEnv))
for k, v := range runEnv {
maskedRunEnv[k] = maskSecrets(v, allSecrets)
}
setServerRun(runID, srvIdx, bson.M{
"server_runs.$.status": status,
"server_runs.$.finished_at": fin,
"server_runs.$.run_env": maskedRunEnv,
})
}
// dispatchAndWait registers a waiter, dispatches the step, and blocks for the
// result or a timeout.
func dispatchAndWait(serverID, commandID string, cmd *pb.RunStepCmd) *pb.StepResult {
ch := StepResults.Await(commandID)
if err := DispatchRunStep(serverID, commandID, cmd); err != nil {
StepResults.Cancel(commandID)
return &pb.StepResult{ExitCode: 1, Stderr: "[vantage] dispatch failed: " + err.Error()}
}
wait := time.Duration(cmd.TimeoutSeconds)*time.Second + stepDispatchGrace
if cmd.TimeoutSeconds == 0 {
wait = 30*time.Minute + stepDispatchGrace
}
select {
case res := <-ch:
return res
case <-time.After(wait):
StepResults.Cancel(commandID)
return &pb.StepResult{ExitCode: 124, Stderr: "[vantage] timed out waiting for agent result"}
}
}
// expandVars substitutes $VAR and ${VAR} references in an input value from the
// given lookup (prior step outputs and secrets). Unknown references expand to
// empty, matching shell behaviour; a literal "$" is written as "$$".
func expandVars(v string, lookup map[string]string) string {
return os.Expand(v, func(name string) string {
if name == "$" {
return "$"
}
return lookup[name]
})
}
func resolveSecrets(refs []string) map[string]string {
out := map[string]string{}
for _, ref := range refs {
// ref format "group/KEY"; resolve via RevealSecret.
parts := strings.SplitN(ref, "/", 2)
if len(parts) != 2 {
continue
}
if v, err := RevealSecret(parts[0], parts[1]); err == nil {
out[parts[1]] = v
}
}
return out
}
func maskSecrets(s string, secrets map[string]string) string {
for _, v := range secrets {
if v == "" {
continue
}
s = strings.ReplaceAll(s, v, "***")
}
return s
}
// ---- run doc mutation helpers ----
func setServerRun(runID string, srvIdx int, set bson.M) {
ctx, cancel := wfCtx()
defer cancel()
_, _ = db.Col("workflow_runs").UpdateOne(ctx,
bson.M{"run_id": runID, "server_runs.server_id": serverIDAt(runID, srvIdx)},
bson.M{"$set": set})
}
// serverIDAt returns the server_id at an index (positional operator needs a match).
func serverIDAt(runID string, srvIdx int) string {
r, err := GetRun(runID)
if err != nil || srvIdx >= len(r.ServerRuns) {
return ""
}
return r.ServerRuns[srvIdx].ServerID
}
func startStep(runID, serverID string, order int, status string) {
now := time.Now()
updateStep(runID, serverID, order, bson.M{
"server_runs.$[s].steps.$[t].status": status,
"server_runs.$[s].steps.$[t].started_at": now,
})
}
func finishStep(runID, serverID string, order int, status string, attempts, exit int, logOffset int64, outEnv map[string]string) {
now := time.Now()
updateStep(runID, serverID, order, bson.M{
"server_runs.$[s].steps.$[t].status": status,
"server_runs.$[s].steps.$[t].attempts": attempts,
"server_runs.$[s].steps.$[t].exit_code": exit,
"server_runs.$[s].steps.$[t].log_offset": logOffset,
"server_runs.$[s].steps.$[t].output_env": outEnv,
"server_runs.$[s].steps.$[t].finished_at": now,
})
}
// secretValues returns just the values of a secret map, for masking log output.
func secretValues(m map[string]string) []string {
out := make([]string, 0, len(m))
for _, v := range m {
out = append(out, v)
}
return out
}
func markRemainingSkipped(runID, serverID string, fromOrder int) {
ctx, cancel := wfCtx()
defer cancel()
_, _ = db.Col("workflow_runs").UpdateMany(ctx,
bson.M{"run_id": runID},
bson.M{"$set": bson.M{"server_runs.$[s].steps.$[t].status": "skipped"}},
options.UpdateMany().SetArrayFilters([]interface{}{
bson.M{"s.server_id": serverID},
bson.M{"t.order": bson.M{"$gte": fromOrder}, "t.status": "queued"},
}),
)
}
func updateStep(runID, serverID string, order int, set bson.M) {
ctx, cancel := wfCtx()
defer cancel()
_, _ = db.Col("workflow_runs").UpdateOne(ctx,
bson.M{"run_id": runID},
bson.M{"$set": set},
options.UpdateOne().SetArrayFilters([]interface{}{
bson.M{"s.server_id": serverID},
bson.M{"t.order": order},
}),
)
}
// ---- reads ----
func GetRun(runID string) (*models.WorkflowRun, error) {
ctx, cancel := wfCtx()
defer cancel()
var r models.WorkflowRun
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&r)
if err == mongo.ErrNoDocuments {
return nil, fmt.Errorf("run not found")
}
return &r, err
}
func ListRuns(workflowID string, limit int64) ([]models.WorkflowRun, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"workflow_id": workflowID},
options.Find().SetSort(bson.D{{Key: "started_at", Value: -1}}).SetLimit(limit))
if err != nil {
return nil, err
}
defer cur.Close(ctx)
runs := []models.WorkflowRun{}
if err := cur.All(ctx, &runs); err != nil {
return nil, err
}
return runs, nil
}
func CancelRun(runID string) error {
now := time.Now()
ctx, cancel := wfCtx()
defer cancel()
_, err := db.Col("workflow_runs").UpdateOne(ctx,
bson.M{"run_id": runID, "status": "running"},
bson.M{"$set": bson.M{"status": "cancelled", "finished_at": now}})
return err
}
+271
View File
@@ -0,0 +1,271 @@
package services
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func wfCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 10*time.Second)
}
func EnsureWorkflowIndexes() error {
ctx, cancel := wfCtx()
defer cancel()
if _, err := db.Col("workflow_steps").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "step_id", Value: 1}}, Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
if _, err := db.Col("workflow_steps").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "slug", Value: 1}},
Options: options.Index().SetUnique(true).
SetPartialFilterExpression(bson.M{"source": "default"}),
}); err != nil {
return err
}
if _, err := db.Col("workflows").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "workflow_id", Value: 1}}, Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
_, err := db.Col("workflow_runs").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "run_id", Value: 1}}, Options: options.Index().SetUnique(true),
})
return err
}
// ---- Steps ----
func ListSteps() ([]models.WorkflowStep, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflow_steps").Find(ctx, bson.M{},
options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
}
defer cur.Close(ctx)
steps := []models.WorkflowStep{}
if err := cur.All(ctx, &steps); err != nil {
return nil, err
}
return steps, nil
}
// StepUsageCounts returns, per library step_id, the number of distinct
// workflows that reference it. Inline steps have no step_id and are ignored.
func StepUsageCounts() (map[string]int, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflows").Find(ctx, bson.M{})
if err != nil {
return nil, err
}
defer cur.Close(ctx)
var wfs []models.Workflow
if err := cur.All(ctx, &wfs); err != nil {
return nil, err
}
counts := map[string]int{}
for _, w := range wfs {
seen := map[string]bool{}
for _, ref := range w.Steps {
if ref.StepID == "" || seen[ref.StepID] {
continue
}
seen[ref.StepID] = true
counts[ref.StepID]++
}
}
return counts, nil
}
func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) {
ctx, cancel := wfCtx()
defer cancel()
s.StepID = uuid.New().String()
s.CreatedAt = time.Now()
s.UpdatedAt = s.CreatedAt
s.DeclaredOutputs = DeriveOutputs(s.Script)
if s.Source == "" {
s.Source = "user"
}
if s.SecretRefs == nil {
s.SecretRefs = []string{}
}
if s.DeclaredInputs == nil {
s.DeclaredInputs = []models.InputParam{}
}
if _, err := db.Col("workflow_steps").InsertOne(ctx, s); err != nil {
return nil, err
}
return &s, nil
}
func UpdateStep(stepID string, s models.WorkflowStep) error {
ctx, cancel := wfCtx()
defer cancel()
_, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID}, bson.M{"$set": bson.M{
"name": s.Name,
"description": s.Description,
"interpreter": s.Interpreter,
"script": s.Script,
"declared_outputs": DeriveOutputs(s.Script),
"declared_inputs": s.DeclaredInputs,
"secret_refs": s.SecretRefs,
"updated_at": time.Now(),
}})
return err
}
func DeleteStep(stepID string) error {
ctx, cancel := wfCtx()
defer cancel()
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID}); err != nil {
return err
}
// Cascade: remove this step from every workflow that references it, re-sequencing orders.
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID})
if err != nil {
return err
}
defer cur.Close(ctx)
var wfs []models.Workflow
if err := cur.All(ctx, &wfs); err != nil {
return err
}
for _, w := range wfs {
kept := make([]models.WorkflowStepRef, 0, len(w.Steps))
for _, ref := range w.Steps {
if ref.StepID == stepID {
continue
}
kept = append(kept, ref)
}
for i := range kept {
kept[i].Order = i
}
if _, err := db.Col("workflows").UpdateOne(ctx,
bson.M{"workflow_id": w.WorkflowID},
bson.M{"$set": bson.M{"steps": kept, "updated_at": time.Now()}},
); err != nil {
return err
}
}
return nil
}
func getStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) {
var s models.WorkflowStep
err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID}).Decode(&s)
if err == mongo.ErrNoDocuments {
return nil, fmt.Errorf("step %s not found", stepID)
}
return &s, err
}
// ---- Workflows ----
func ListWorkflows() ([]models.Workflow, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflows").Find(ctx, bson.M{},
options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
}
defer cur.Close(ctx)
wfs := []models.Workflow{}
if err := cur.All(ctx, &wfs); err != nil {
return nil, err
}
return wfs, nil
}
func GetWorkflow(id string) (*models.Workflow, error) {
ctx, cancel := wfCtx()
defer cancel()
var w models.Workflow
err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id}).Decode(&w)
if err == mongo.ErrNoDocuments {
return nil, fmt.Errorf("workflow not found")
}
return &w, err
}
func CreateWorkflow(w models.Workflow) (*models.Workflow, error) {
ctx, cancel := wfCtx()
defer cancel()
w.WorkflowID = uuid.New().String()
w.CreatedAt = time.Now()
w.UpdatedAt = w.CreatedAt
if w.TargetServerIDs == nil {
w.TargetServerIDs = []string{}
}
if w.Steps == nil {
w.Steps = []models.WorkflowStepRef{}
}
if err := ValidateWorkflow(w); err != nil {
return nil, err
}
normalizeInlineSteps(&w)
if _, err := db.Col("workflows").InsertOne(ctx, w); err != nil {
return nil, err
}
return &w, nil
}
func UpdateWorkflow(id string, w models.Workflow) error {
ctx, cancel := wfCtx()
defer cancel()
if err := ValidateWorkflow(w); err != nil {
return err
}
normalizeInlineSteps(&w)
_, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id}, bson.M{"$set": bson.M{
"name": w.Name,
"target_server_ids": w.TargetServerIDs,
"steps": w.Steps,
"updated_at": time.Now(),
}})
return err
}
// normalizeInlineSteps derives outputs for inline steps and strips fields that
// only belong to library steps.
func normalizeInlineSteps(w *models.Workflow) {
for i := range w.Steps {
in := w.Steps[i].Inline
if in == nil {
continue
}
in.DeclaredOutputs = DeriveOutputs(in.Script)
in.StepID = ""
in.Slug = ""
in.Source = ""
in.CreatedAt = time.Time{}
in.UpdatedAt = time.Time{}
if in.SecretRefs == nil {
in.SecretRefs = []string{}
}
if in.DeclaredInputs == nil {
in.DeclaredInputs = []models.InputParam{}
}
}
}
func DeleteWorkflow(id string) error {
ctx, cancel := wfCtx()
defer cancel()
_, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id})
return err
}
@@ -0,0 +1,66 @@
package services
import (
"os"
"testing"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
)
var mongoAvailable bool
func TestMain(m *testing.M) {
uri := os.Getenv("VANTAGE_TEST_MONGO_URI")
if uri == "" {
uri = "mongodb://localhost:27117"
}
if err := db.Connect(uri, "vantage_test"); err != nil {
// No MongoDB available in this environment; DB-backed tests will be skipped
// individually, but the rest of the package's tests must still run.
mongoAvailable = false
} else {
mongoAvailable = true
}
os.Exit(m.Run())
}
func mkUsageStep(name string) models.WorkflowStep {
return models.WorkflowStep{Name: name, Interpreter: "bash", Script: "echo hi"}
}
func mkWorkflowWithStep(name, stepID string) models.Workflow {
return models.Workflow{Name: name, Steps: []models.WorkflowStepRef{{StepID: stepID, Order: 0, OnFailure: "stop"}}}
}
func TestStepUsageCounts(t *testing.T) {
if !mongoAvailable {
t.Skip("mongo unavailable: set VANTAGE_TEST_MONGO_URI")
}
// A step used by two workflows, a step used by none.
used, err := CreateStep(mkUsageStep("used-step"))
if err != nil {
t.Fatal(err)
}
unused, err := CreateStep(mkUsageStep("unused-step"))
if err != nil {
t.Fatal(err)
}
if _, err := CreateWorkflow(mkWorkflowWithStep("wf-a", used.StepID)); err != nil {
t.Fatal(err)
}
if _, err := CreateWorkflow(mkWorkflowWithStep("wf-b", used.StepID)); err != nil {
t.Fatal(err)
}
counts, err := StepUsageCounts()
if err != nil {
t.Fatal(err)
}
if counts[used.StepID] != 2 {
t.Fatalf("used step: want 2, got %d", counts[used.StepID])
}
if counts[unused.StepID] != 0 {
t.Fatalf("unused step: want 0, got %d", counts[unused.StepID])
}
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api, AuditEvent } from "@/lib/api";
import { Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const EVENT_LABELS: Record<string, string> = {
"server.created": "Server Created",
"server.deleted": "Server Deleted",
"server.offline": "Server Offline",
"key.uploaded": "Key Uploaded",
"key.deleted": "Key Deleted",
"key.assigned": "Key Assigned",
"key.revoked": "Key Revoked",
"key.generation_dispatched": "Key Generation",
"agent.update_dispatched": "Agent Updated",
"updates.applied": "Updates Applied",
"settings.updated": "Settings Updated",
};
const EVENT_COLOURS: Record<string, string> = {
"server.offline": "text-danger",
"server.deleted": "text-danger",
"key.deleted": "text-danger",
"key.revoked": "text-warning",
"server.created": "text-success",
"key.uploaded": "text-success",
"key.assigned": "text-success",
};
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString();
}
function EventTypeBadge({ type }: { type: string }) {
const label = EVENT_LABELS[type] ?? type;
const colour = EVENT_COLOURS[type] ?? "text-text-secondary";
return (
<span className={`font-mono text-xs font-medium ${colour}`}>{label}</span>
);
}
export default function AuditPage() {
const { data: events, isLoading, error } = useQuery({
queryKey: ["audit"],
queryFn: () => api.listAuditEvents(200),
refetchInterval: 30_000,
});
return (
<div className="p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Audit Log</h1>
<p className="mt-1 text-sm text-text-secondary">
All administrative actions and server status changes
</p>
</div>
<Card padding={false}>
{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>
) : error ? (
<div className="py-20 text-center text-danger">Failed to load audit log.</div>
) : events && events.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Time</Th>
<Th>Event</Th>
<Th>Actor</Th>
<Th>Details</Th>
</Tr>
</Thead>
<Tbody>
{events.map((e: AuditEvent) => (
<Tr key={e.id}>
<Td>
<span className="whitespace-nowrap font-mono text-xs text-text-secondary">
{formatDate(e.created_at)}
</span>
</Td>
<Td>
<EventTypeBadge type={e.event_type} />
</Td>
<Td>
<span className="text-sm text-text-primary">{e.actor}</span>
</Td>
<Td>
<span className="text-sm text-text-secondary">{e.details}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<p className="text-text-secondary text-sm">No audit events recorded yet.</p>
</div>
)}
</Card>
</div>
);
}
+19
View File
@@ -36,3 +36,22 @@ body {
::-webkit-scrollbar-thumb:hover {
background: #3e4160;
}
@keyframes led-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.led-pulse { animation: led-pulse 1.4s ease-in-out infinite; }
@keyframes cell-ring {
0%, 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.5); }
50% { box-shadow: 0 0 0 4px rgba(99, 102, 241, 0); }
}
.cell-ring { animation: cell-ring 1.4s ease-in-out infinite; }
@keyframes caret-blink { 50% { opacity: 0; } }
.caret-blink { animation: caret-blink 1s step-end infinite; }
@media (prefers-reduced-motion: reduce) {
.led-pulse, .cell-ring, .caret-blink { animation: none; }
}
+15 -1
View File
@@ -12,9 +12,10 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
const [label, setLabel] = useState("");
const [publicKey, setPublicKey] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [passphrase, setPassphrase] = useState("");
const { mutate: upload, isPending, error } = useMutation({
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined),
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim(), privateKey.trim() || undefined, passphrase || undefined),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys"] });
onClose();
@@ -70,6 +71,19 @@ function UploadKeyModal({ onClose }: { onClose: () => void }) {
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Passphrase{" "}
<span className="text-text-tertiary font-normal">(optional — for an encrypted private key)</span>
</label>
<input
type="password"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
+1 -1
View File
@@ -5,7 +5,7 @@ import { AuthProvider } from "@/components/AuthProvider";
import { Sidebar } from "@/components/Sidebar";
export const metadata: Metadata = {
title: "KeyManager",
title: "Vantage",
description: "Self-hosted SSH key management",
};
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, MonitorInput } from "@/lib/api";
import { Card } from "@/components/ui";
import { MonitorForm } from "@/components/monitors/MonitorForm";
export default function EditMonitorPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const monitorId = params.id as string;
const { data: monitor, isLoading } = useQuery({
queryKey: ["monitors", monitorId],
queryFn: () => api.getMonitor(monitorId),
});
const { mutate: update, isPending, error } = useMutation({
mutationFn: (input: MonitorInput) => api.updateMonitor(monitorId, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["monitors"] });
queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] });
router.push(`/monitors/${monitorId}`);
},
});
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
if (!monitor) {
return (
<div className="p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Monitor not found.</div>
</div>
);
}
return (
<div className="p-8">
<Link href={`/monitors/${monitorId}`} className="text-sm text-text-secondary hover:text-text-primary">
← {monitor.name}
</Link>
<h1 className="mb-6 mt-2 text-2xl font-bold text-text-primary">Edit Monitor</h1>
<Card className="max-w-2xl">
<MonitorForm initial={monitor} submitLabel="Save Changes" onSubmit={update} isPending={isPending} error={error as Error | null} />
</Card>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, Monitor, MonitorStatus, Rollup } from "@/lib/api";
import { Badge, Button, Card, CardHeader, CardTitle, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
function statusVariant(status: MonitorStatus) {
switch (status) {
case "up":
return "success";
case "down":
return "danger";
default:
return "warning";
}
}
function uptimePct(rollups: Rollup[]): number {
const checks = rollups.reduce((a, r) => a + r.checks, 0);
const up = rollups.reduce((a, r) => a + r.up_count, 0);
return checks > 0 ? (up / checks) * 100 : 0;
}
function Heartbeat({ rollups }: { rollups: Rollup[] }) {
const recent = rollups.slice(-48);
return (
<div className="flex items-end gap-0.5">
{recent.map((r) => {
const pct = r.checks > 0 ? (r.up_count / r.checks) * 100 : 0;
const color = r.checks === 0 ? "bg-surface-2" : pct >= 99 ? "bg-success" : pct >= 80 ? "bg-warning" : "bg-danger";
return (
<div
key={r.period_start}
className={`h-8 w-1.5 rounded-sm ${color}`}
title={`${new Date(r.period_start).toLocaleString()} — ${pct.toFixed(0)}% up`}
/>
);
})}
{recent.length === 0 && <span className="text-xs text-text-secondary">No history yet.</span>}
</div>
);
}
export default function MonitorDetailPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const monitorId = params.id as string;
const [confirmDelete, setConfirmDelete] = useState(false);
const { data: monitor, isLoading } = useQuery({
queryKey: ["monitors", monitorId],
queryFn: () => api.getMonitor(monitorId),
refetchInterval: 30_000,
});
const { data: rollups } = useQuery({
queryKey: ["monitors", monitorId, "uptime"],
queryFn: () => api.getMonitorUptime(monitorId),
refetchInterval: 60_000,
});
const { data: incidents } = useQuery({
queryKey: ["monitors", monitorId, "incidents"],
queryFn: () => api.getMonitorIncidents(monitorId),
refetchInterval: 60_000,
});
const { mutate: deleteMonitor, isPending: isDeleting } = useMutation({
mutationFn: () => api.deleteMonitor(monitorId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["monitors"] });
router.push("/monitors");
},
});
const { mutate: toggleEnabled } = useMutation({
mutationFn: (enabled: boolean) => api.updateMonitor(monitorId, { enabled }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["monitors", monitorId] }),
});
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
if (!monitor) {
return (
<div className="p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Monitor not found.</div>
</div>
);
}
const all = rollups ?? [];
const last24 = all.slice(-24);
return (
<div className="p-8">
<div className="mb-6 flex items-start justify-between">
<div>
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
← Monitors
</Link>
<div className="mt-2 flex items-center gap-3">
<h1 className="text-2xl font-bold text-text-primary">{monitor.name}</h1>
<Badge variant={statusVariant(monitor.state.status)}>{monitor.state.status}</Badge>
<Badge variant="neutral">{monitor.type}</Badge>
{!monitor.enabled && <Badge variant="warning">disabled</Badge>}
</div>
{monitor.state.message && <p className="mt-1 text-sm text-text-secondary">{monitor.state.message}</p>}
</div>
<div className="flex gap-2">
<Link href={`/monitors/${monitorId}/edit`}>
<Button variant="secondary">Edit</Button>
</Link>
<Button variant="secondary" onClick={() => toggleEnabled(!monitor.enabled)}>
{monitor.enabled ? "Disable" : "Enable"}
</Button>
{!confirmDelete ? (
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Delete
</Button>
) : (
<div className="flex items-center gap-2">
<span className="text-sm text-danger">Are you sure?</span>
<Button variant="danger" loading={isDeleting} onClick={() => deleteMonitor()}>
Confirm
</Button>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</div>
)}
</div>
</div>
<div className="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
<Card>
<p className="text-xs text-text-secondary">Uptime (24h)</p>
<p className="mt-1 text-2xl font-bold text-text-primary">{uptimePct(last24).toFixed(1)}%</p>
</Card>
<Card>
<p className="text-xs text-text-secondary">Uptime (30d)</p>
<p className="mt-1 text-2xl font-bold text-text-primary">{uptimePct(all).toFixed(1)}%</p>
</Card>
<Card>
<p className="text-xs text-text-secondary">Latency</p>
<p className="mt-1 text-2xl font-bold text-text-primary">{monitor.state.latency_ms}ms</p>
</Card>
<Card>
<p className="text-xs text-text-secondary">Cert expiry</p>
<p className="mt-1 text-sm font-medium text-text-primary">
{monitor.state.cert_expiry_at ? new Date(monitor.state.cert_expiry_at).toLocaleDateString() : "—"}
</p>
</Card>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>Heartbeat (last 48h)</CardTitle>
</CardHeader>
<Heartbeat rollups={all} />
</Card>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card padding={false}>
<div className="border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">Incidents</h2>
</div>
{!incidents || incidents.length === 0 ? (
<div className="py-12 text-center text-sm text-text-secondary">No incidents recorded.</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Started</Th>
<Th>Resolved</Th>
<Th>Cause</Th>
</Tr>
</Thead>
<Tbody>
{incidents.map((inc) => (
<Tr key={inc.incident_id}>
<Td>
<span className="text-xs text-text-secondary">{new Date(inc.started_at).toLocaleString()}</span>
</Td>
<Td>
{inc.resolved_at ? (
<span className="text-xs text-text-secondary">{new Date(inc.resolved_at).toLocaleString()}</span>
) : (
<Badge variant="danger">ongoing</Badge>
)}
</Td>
<Td>
<span className="text-xs text-text-primary">{inc.cause || "—"}</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
<Card>
<CardHeader>
<CardTitle>Configuration</CardTitle>
</CardHeader>
<dl className="space-y-3 text-sm">
<div>
<dt className="text-text-secondary">Runner</dt>
<dd className="mt-0.5 font-mono text-text-primary">{monitor.runner}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Interval</dt>
<dd className="mt-0.5 text-text-primary">{monitor.interval_sec}s</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Retries before down</dt>
<dd className="mt-0.5 text-text-primary">{monitor.retries}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Target</dt>
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">
{monitor.target.url || `${monitor.target.host ?? ""}${monitor.target.port ? `:${monitor.target.port}` : ""}`}
</dd>
</div>
</dl>
</Card>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { api, MonitorInput } from "@/lib/api";
import { Card } from "@/components/ui";
import { MonitorForm } from "@/components/monitors/MonitorForm";
export default function NewMonitorPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { mutate: create, isPending, error } = useMutation({
mutationFn: (input: MonitorInput) => api.createMonitor(input),
onSuccess: (m) => {
queryClient.invalidateQueries({ queryKey: ["monitors"] });
router.push(`/monitors/${m.monitor_id}`);
},
});
return (
<div className="p-8">
<Link href="/monitors" className="text-sm text-text-secondary hover:text-text-primary">
← Monitors
</Link>
<h1 className="mb-6 mt-2 text-2xl font-bold text-text-primary">New Monitor</h1>
<Card className="max-w-2xl">
<MonitorForm submitLabel="Create Monitor" onSubmit={create} isPending={isPending} error={error as Error | null} />
</Card>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { api, Monitor, MonitorStatus } from "@/lib/api";
import { Badge, Button, Card, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
function statusVariant(status: MonitorStatus) {
switch (status) {
case "up":
return "success";
case "down":
return "danger";
default:
return "warning";
}
}
function targetSummary(m: Monitor): string {
if (m.type === "http") return m.target.url ?? "";
if (m.type === "tls") return `${m.target.host ?? ""}:${m.target.port || 443}`;
if (m.type === "icmp") return m.target.host ?? "";
return `${m.target.host ?? ""}:${m.target.port ?? ""}`;
}
export default function MonitorsPage() {
const { data: monitors, isLoading } = useQuery({
queryKey: ["monitors"],
queryFn: () => api.listMonitors(),
refetchInterval: 30_000,
});
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Monitors</h1>
<p className="mt-1 text-sm text-text-secondary">Service uptime and latency checks.</p>
</div>
<div className="flex gap-2">
<Link href="/settings/notifications">
<Button variant="secondary">Notifications</Button>
</Link>
<Link href="/monitors/new">
<Button variant="primary">New Monitor</Button>
</Link>
</div>
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : !monitors || monitors.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-text-secondary">No monitors yet.</p>
<Link href="/monitors/new">
<Button variant="secondary" size="sm" className="mt-3">
Create your first monitor
</Button>
</Link>
</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Name</Th>
<Th>Type</Th>
<Th>Target</Th>
<Th>Status</Th>
<Th>Latency</Th>
<Th>Last check</Th>
</Tr>
</Thead>
<Tbody>
{monitors.map((m) => (
<Tr key={m.monitor_id}>
<Td>
<Link href={`/monitors/${m.monitor_id}`} className="font-medium text-text-primary hover:text-accent">
{m.name}
</Link>
</Td>
<Td>
<Badge variant="neutral">{m.type}</Badge>
</Td>
<Td>
<span className="font-mono text-xs text-text-secondary">{targetSummary(m)}</span>
</Td>
<Td>
<Badge variant={statusVariant(m.state.status)}>{m.state.status}</Badge>
</Td>
<Td>
<span className="text-sm text-text-secondary">{m.state.latency_ms}ms</span>
</Td>
<Td>
<span className="text-xs text-text-secondary">
{m.state.last_check_at ? new Date(m.state.last_check_at).toLocaleTimeString() : "—"}
</span>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
</div>
);
}
+311
View File
@@ -0,0 +1,311 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, Secret } from "@/lib/api";
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
// Name of the ClusterSecretStore the generated manifests reference.
const STORE_NAME = "vantage-store";
function CopyBlock({ label, yaml }: { label: string; yaml: string }) {
const [copied, setCopied] = useState(false);
async function copy() {
await navigator.clipboard.writeText(yaml);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-sm font-medium text-text-secondary">{label}</span>
<Button type="button" variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="overflow-x-auto rounded-lg border border-border bg-surface-2 p-3 font-mono text-xs leading-relaxed text-text-primary">{yaml}</pre>
</div>
);
}
function YamlModal({ group, onClose }: { group: string; onClose: () => void }) {
const [namespace, setNamespace] = useState(group);
const readUrl = typeof window !== "undefined" ? window.location.origin : "https://vantage.example.com";
const ns = namespace.trim() || group;
const externalSecret = `apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: ${group}
namespace: ${ns}
spec:
refreshInterval: 15m
secretStoreRef:
name: ${STORE_NAME}
kind: ClusterSecretStore
target:
name: ${group}
creationPolicy: Owner
dataFrom:
- extract:
key: ${group}`;
const clusterStore = `apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: ${STORE_NAME}
spec:
provider:
webhook:
url: "${readUrl}/api/secrets/{{ .remoteRef.key }}/values"
method: GET
result:
jsonPath: "$"
headers:
Content-Type: "application/json"
Authorization: "Bearer {{ .auth.token }}"
secrets:
- name: auth
secretRef:
name: vantage-eso-token
namespace: external-secrets`;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-border bg-surface p-6">
<h2 className="mb-1 text-lg font-semibold text-text-primary">
Kubernetes manifests for <span className="font-mono">{group}</span>
</h2>
<p className="mb-5 text-sm text-text-secondary">Apply the ExternalSecret in your app&apos;s namespace to sync this group into a Kubernetes Secret via ESO.</p>
<div className="mb-5">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Namespace</label>
<input type="text" value={namespace} onChange={(e) => setNamespace(e.target.value)} placeholder={group} className={`${inputClass} font-mono`} />
</div>
<div className="space-y-5">
<CopyBlock label="ExternalSecret (apply per namespace)" yaml={externalSecret} />
<details className="group">
<summary className="cursor-pointer text-sm font-medium text-text-secondary hover:text-text-primary">One-time cluster setup: ClusterSecretStore</summary>
<p className="mb-3 mt-2 text-xs text-text-tertiary">
Apply this once per cluster. It requires a Secret named <span className="font-mono">vantage-eso-token</span> in the <span className="font-mono">external-secrets</span>{" "}
namespace holding the read token from Settings, labelled <span className="font-mono">external-secrets.io/type=webhook</span>.
</p>
<CopyBlock label="ClusterSecretStore" yaml={clusterStore} />
</details>
</div>
<div className="mt-6 flex justify-end">
<Button variant="ghost" onClick={onClose}>
Close
</Button>
</div>
</div>
</div>
);
}
function SecretRow({ group, secret }: { group: string; secret: Secret }) {
const queryClient = useQueryClient();
const [revealed, setRevealed] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { mutate: reveal, isPending: revealing } = useMutation({
mutationFn: () => api.revealSecret(group, secret.key),
onSuccess: (res) => setRevealed(res.value),
});
const { mutate: remove, isPending: removing } = useMutation({
mutationFn: () => api.deleteSecret(group, secret.key),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["secret-group", group] }),
});
async function copy() {
if (revealed == null) return;
await navigator.clipboard.writeText(revealed);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<Tr>
<Td>
<span className="font-mono font-medium text-text-primary">{secret.key}</span>
</Td>
<Td>{revealed == null ? <span className="font-mono text-text-tertiary">••••••••••••</span> : <span className="font-mono text-xs break-all text-text-primary">{revealed}</span>}</Td>
<Td>
<span className="text-text-secondary text-xs">{new Date(secret.updated_at).toLocaleString()}</span>
</Td>
<Td>
<div className="flex justify-end gap-2">
{revealed == null ? (
<Button variant="ghost" size="sm" loading={revealing} onClick={() => reveal()}>
Reveal
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={copy}>
{copied ? "Copied!" : "Copy"}
</Button>
<Button variant="ghost" size="sm" onClick={() => setRevealed(null)}>
Hide
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
loading={removing}
className="text-danger hover:text-danger"
onClick={() => {
if (confirm(`Delete key "${secret.key}"?`)) remove();
}}
>
Delete
</Button>
</div>
</Td>
</Tr>
);
}
function AddKeyCard({ group }: { group: string }) {
const queryClient = useQueryClient();
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const {
mutate: add,
isPending,
error,
} = useMutation({
mutationFn: () => api.putSecrets(group, { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-group", group] });
setKey("");
setValue("");
},
});
return (
<Card>
<CardHeader>
<CardTitle>Add / Update Key</CardTitle>
</CardHeader>
<p className="mb-4 text-sm text-text-secondary">Adding a key that already exists overwrites its value. Others are left untouched.</p>
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{(error as Error).message}</div>}
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Key</label>
<input type="text" value={key} onChange={(e) => setKey(e.target.value)} placeholder="API_KEY" className={`${inputClass} font-mono`} />
</div>
<div className="flex-1">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Value</label>
<input type="text" value={value} onChange={(e) => setValue(e.target.value)} placeholder="myapikey456" className={`${inputClass} font-mono`} />
</div>
<Button variant="primary" loading={isPending} disabled={!key.trim() || !value} onClick={() => add()}>
Save
</Button>
</div>
</Card>
);
}
export default function SecretGroupPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const group = decodeURIComponent(String(params.group));
const [showYaml, setShowYaml] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["secret-group", group],
queryFn: () => api.getSecretGroup(group),
});
const { mutate: deleteGroup, isPending: deleting } = useMutation({
mutationFn: () => api.deleteSecretGroup(group),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
router.push("/secrets");
},
});
return (
<div className="p-8">
{showYaml && <YamlModal group={group} onClose={() => setShowYaml(false)} />}
<Link href="/secrets" className="mb-4 inline-flex items-center gap-1 text-sm text-text-secondary hover:text-text-primary">
← Back to secrets
</Link>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-mono text-2xl font-bold text-text-primary">{group}</h1>
<p className="mt-1 text-sm text-text-secondary">
ESO reads this group at <span className="font-mono">GET /api/secrets/{group}/values</span>
</p>
</div>
<div className="flex items-center gap-3">
<Button variant="ghost" onClick={() => setShowYaml(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" />
</svg>
ExternalSecret YAML
</Button>
<Button
variant="ghost"
className="text-danger hover:text-danger"
loading={deleting}
onClick={() => {
if (confirm(`Delete the entire "${group}" group and all its keys?`)) deleteGroup();
}}
>
Delete Group
</Button>
</div>
</div>
<div className="space-y-6">
<AddKeyCard group={group} />
<Card padding={false}>
{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>
) : error ? (
<div className="py-20 text-center text-danger">Failed to load group. It may have been deleted.</div>
) : data && data.secrets.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Key</Th>
<Th>Value</Th>
<Th>Updated</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{data.secrets.map((s: Secret) => (
<SecretRow key={s.key} group={group} secret={s} />
))}
</Tbody>
</Table>
) : (
<div className="py-16 text-center text-text-secondary">This group has no keys. Add one above.</div>
)}
</Card>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, SecretGroupSummary } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
const inputClass =
"w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent";
function NewGroupModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient();
const [group, setGroup] = useState("");
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const { mutate: create, isPending, error } = useMutation({
mutationFn: () =>
api.createSecretGroup(group.trim(), { [key.trim()]: value }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["secret-groups"] });
onClose();
},
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-border bg-surface p-6">
<h2 className="mb-1 text-lg font-semibold text-text-primary">New Secret Group</h2>
<p className="mb-4 text-sm text-text-secondary">
A group must be created with at least one key. You can add more keys afterwards.
</p>
{error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(error as Error).message}
</div>
)}
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Group name</label>
<input
type="text"
value={group}
onChange={(e) => setGroup(e.target.value)}
placeholder="e.g. myapp-prod"
className={`${inputClass} font-mono`}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">First key</label>
<input
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="DB_PASSWORD"
className={`${inputClass} font-mono`}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Value</label>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="supersecret123"
className={`${inputClass} font-mono`}
/>
</div>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button
variant="primary"
loading={isPending}
disabled={!group.trim() || !key.trim() || !value}
onClick={() => create()}
>
Create Group
</Button>
</div>
</div>
</div>
);
}
export default function SecretsPage() {
const [showNew, setShowNew] = useState(false);
const { data: groups, isLoading, error } = useQuery({
queryKey: ["secret-groups"],
queryFn: api.listSecretGroups,
});
return (
<div className="p-8">
{showNew && <NewGroupModal onClose={() => setShowNew(false)} />}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Secrets</h1>
<p className="mt-1 text-sm text-text-secondary">
{groups?.length ?? 0} group{groups?.length !== 1 ? "s" : ""} · encrypted at rest, exposed to Kubernetes via ESO
</p>
</div>
<Button variant="primary" onClick={() => setShowNew(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New Group
</Button>
</div>
<Card padding={false}>
{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>
) : error ? (
<div className="py-20 text-center text-danger">
Failed to load secrets. Is the backend running?
</div>
) : groups && groups.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Group</Th>
<Th>Keys</Th>
<Th>Last Updated</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{groups.map((g: SecretGroupSummary) => (
<Tr key={g.group}>
<Td>
<span className="font-mono font-medium text-text-primary">{g.group}</span>
</Td>
<Td>
<span className="text-text-secondary">
{g.key_count} key{g.key_count !== 1 ? "s" : ""}
</span>
</Td>
<Td>
<span className="text-text-secondary text-xs">
{new Date(g.updated_at).toLocaleString()}
</span>
</Td>
<Td>
<Link href={`/secrets/${encodeURIComponent(g.group)}`}>
<Button variant="ghost" size="sm">View →</Button>
</Link>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</div>
<p className="text-text-secondary">No secret groups yet.</p>
<Button variant="primary" size="sm" className="mt-4" onClick={() => setShowNew(true)}>
Create your first group
</Button>
</div>
)}
</Card>
</div>
);
}
+296
View File
@@ -0,0 +1,296 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import { Button, Card } from "@/components/ui";
import { openConsole } from "@/lib/guacConsole";
export default function ServerConsolePage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const serverId = params.id as string;
const containerRef = useRef<HTMLDivElement>(null);
const connectionRef = useRef<{
disconnect: () => void;
setScale: (scale: number) => void;
resize: (width: number, height: number) => void;
} | null>(null);
const [protocol, setProtocol] = useState<string>(searchParams.get("protocol") || "");
const [keyId, setKeyId] = useState<string>("");
const [sshUsername, setSshUsername] = useState<string>("root");
const [rdpUsername, setRdpUsername] = useState("");
const [rdpPassword, setRdpPassword] = useState("");
const [vncPassword, setVncPassword] = useState("");
const [connecting, setConnecting] = useState(false);
const [connected, setConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<{ token: string; wsPath: string } | null>(null);
const [zoom, setZoom] = useState(1);
const dprRef = useRef(1);
// Inject the vendored Guacamole client script once.
useEffect(() => {
const s = document.createElement("script");
s.src = "/lib/guacamole-common.js";
s.async = true;
document.body.appendChild(s);
return () => {
document.body.removeChild(s);
};
}, []);
// Disconnect on unmount.
useEffect(() => {
return () => {
connectionRef.current?.disconnect();
};
}, []);
const { data: server, isLoading: serverLoading } = useQuery({
queryKey: ["servers", serverId],
queryFn: () => api.getServer(serverId),
});
const { data: keys, isLoading: keysLoading } = useQuery({
queryKey: ["keys"],
queryFn: () => api.listKeys(),
});
const usableKeys = useMemo(() => (keys ?? []).filter((k) => k.has_private_key === true), [keys]);
const protocols = server?.console_protocols ?? [];
useEffect(() => {
if (!protocol && protocols.length > 0) {
setProtocol(protocols[0]);
}
}, [protocols, protocol]);
async function handleConnect() {
setError(null);
setConnecting(true);
try {
const body: Parameters<typeof api.connectConsole>[0] = {
server_id: serverId,
protocol,
};
if (protocol === "ssh") {
body.key_id = keyId || undefined;
body.ssh_username = sshUsername || undefined;
} else if (protocol === "rdp") {
body.rdp_username = rdpUsername || undefined;
body.rdp_password = rdpPassword || undefined;
} else if (protocol === "vnc") {
body.rdp_password = vncPassword || undefined;
}
const { token, ws_path } = await api.connectConsole(body);
// Defer the actual openConsole until after the form is unmounted so the
// container measures at full height (see effect below).
setPending({ token, wsPath: ws_path });
setConnected(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to connect");
} finally {
setConnecting(false);
}
}
// Runs after `connected` flips and the connection form is gone, so the
// container now occupies its full flex height.
useEffect(() => {
if (!connected || !pending || !containerRef.current) return;
const wsProto = location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${wsProto}://${location.host}${pending.wsPath}`;
const rect = containerRef.current.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
dprRef.current = dpr;
// Request the remote at device-pixel resolution with a fixed 96 dpi, then
// scale the display back down by dpr. Folding dpr into `dpi` instead makes
// the remote enlarge everything, which reads as a zoomed-in view.
const connectData =
`token=${encodeURIComponent(pending.token)}` +
`&width=${Math.floor(rect.width * dpr)}` +
`&height=${Math.floor(rect.height * dpr)}` +
`&dpi=96`;
connectionRef.current = openConsole(containerRef.current, wsUrl, connectData);
connectionRef.current.setScale(zoom / dpr);
setPending(null);
}, [connected, pending]);
// Apply zoom live without reconnecting: resize the remote to a resolution
// that, once scaled to fit the container, yields the requested zoom. Higher
// zoom = fewer remote pixels rendered larger. Display always fits the
// container exactly, so no scrollbars appear.
useEffect(() => {
if (!connectionRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const dpr = dprRef.current;
const remoteW = Math.floor((rect.width * dpr) / zoom);
const remoteH = Math.floor((rect.height * dpr) / zoom);
connectionRef.current.resize(remoteW, remoteH);
connectionRef.current.setScale(zoom / dpr);
}, [zoom]);
function handleDisconnect() {
connectionRef.current?.disconnect();
connectionRef.current = null;
setConnected(false);
if (containerRef.current) {
containerRef.current.innerHTML = "";
}
}
if (serverLoading || keysLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
if (!server) {
return (
<div className="p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">Server not found or failed to load.</div>
</div>
);
}
return (
<div className="flex h-full flex-col p-8">
<div className="mb-4 flex items-center gap-3">
<Link href={`/servers/${serverId}`} className="text-text-secondary hover:text-text-primary text-sm">
← {server.hostname}
</Link>
</div>
<h1 className="mb-4 text-2xl font-bold text-text-primary">Console</h1>
{error && <div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">{error}</div>}
{!connected ? (
<Card className="mb-4 max-w-xl">
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Protocol</label>
<select
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
{protocols.length === 0 && <option value="">No protocols available</option>}
{protocols.map((p) => (
<option key={p} value={p}>
{p.toUpperCase()}
</option>
))}
</select>
</div>
{protocol === "ssh" && (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">SSH Username</label>
<input
type="text"
value={sshUsername}
onChange={(e) => setSshUsername(e.target.value)}
placeholder="root"
className="mb-3 w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">SSH Key</label>
<select
value={keyId}
onChange={(e) => setKeyId(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
<option value="">Select a key…</option>
{usableKeys.map((k) => (
<option key={k.key_id} value={k.key_id}>
{k.label}
</option>
))}
</select>
{usableKeys.length === 0 && (
<p className="mt-1.5 text-xs text-text-tertiary">No keys with stored private material are available.</p>
)}
</div>
)}
{protocol === "rdp" && (
<>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Username</label>
<input
type="text"
value={rdpUsername}
onChange={(e) => setRdpUsername(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Password</label>
<input
type="password"
value={rdpPassword}
onChange={(e) => setRdpPassword(e.target.value)}
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
</>
)}
{protocol === "vnc" && (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Password</label>
<input
type="password"
value={vncPassword}
onChange={(e) => setVncPassword(e.target.value)}
autoComplete="new-password"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
)}
<Button variant="primary" loading={connecting} disabled={!protocol} onClick={handleConnect}>
Connect
</Button>
</div>
</Card>
) : (
<div className="mb-4 flex items-center gap-3">
<Button variant="danger" onClick={handleDisconnect}>
Disconnect
</Button>
<label className="text-sm text-text-secondary">Scale</label>
<select
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
>
<option value={0.5}>50%</option>
<option value={0.75}>75%</option>
<option value={1}>100%</option>
<option value={1.25}>125%</option>
<option value={1.5}>150%</option>
<option value={2}>200%</option>
</select>
</div>
)}
<div
ref={containerRef}
className="min-h-[500px] flex-1 overflow-hidden rounded-lg border border-border bg-black"
/>
</div>
);
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more