Compare commits

..
226 Commits
Author SHA1 Message Date
mrhid6 c4f1684304 docs(plan): drop backfill, add staff instance attach
Server Deploy / deploy (push) Failing after 37s
Existing cloud instances get licensed by hand through the admin UI instead of
an automated backfill. That needs POST /api/staff/instances, which nothing else
provided — without it there is no way to attach an existing cloud instance to
an account.
2026-07-24 16:08:29 +01:00
mrhid6 8ed3bec511 docs: mark plans 1 and 2 shipped 2026-07-24 16:00:45 +01:00
mrhid6 58faf2e57f docs: add the admin-backend implementation plan 2026-07-24 16:00:37 +01:00
mrhid6 8e5f35f40c revert(server): drop the licence grandfather migration
Instances stay read-only until a licence is set. No licence is generated by
the control plane, which keeps the signing key out of it entirely.
2026-07-24 15:49:46 +01:00
mrhid6 eb8b85ccfe fix(server): add the lk go.sum entry the workspace was masking
Server Deploy / deploy (push) Successful in 2m21s
go build inside the Docker image runs outside the workspace, so server/go.sum
needed the hyperboloide/lk entry that GOWORK resolution was supplying locally.
Caught by the image build, not by go build.
2026-07-24 15:35:01 +01:00
mrhid6 09a39090c6 feat(server): grandfather existing cloud instances onto Professional 2026-07-24 15:26:26 +01:00
mrhid6 8626898e5e feat(web): licence banner, settings page and feature gating 2026-07-24 15:25:29 +01:00
mrhid6 ee4dff09c9 feat: show the instance ID after setup 2026-07-24 15:20:00 +01:00
mrhid6 8d88b16f20 feat(server): keep monitors and in-flight runs going when a licence lapses
Monitor execution and the workflow runner are deliberately unguarded: billing
state must not take away a customer's ability to know their infrastructure is
on fire, and killing a run midway leaves a half-configured server.

The plan called for a server-limit check in gRPC Register. Left out: a server
row only comes from CreateServer, which already checks the cap, so counting in
Register counts the caller itself and would reject a legitimate agent at
exactly the cap.
2026-07-24 15:15:06 +01:00
mrhid6 120c9c6735 feat(server): licence API, mutation gate and feature gates
RequireActiveLicense is mounted on the /api group so new routes are gated by
where they live. GET /api/servers/new is named explicitly: it mints a
pre-registration token, so it mutates despite the method.
2026-07-24 15:13:35 +01:00
mrhid6 855537c535 feat(server): enforce licence limits on servers, secret groups and channels 2026-07-24 15:11:03 +01:00
mrhid6 1d3fcebb28 feat(server): resolve licence state per instance 2026-07-24 15:09:47 +01:00
mrhid6 f9f382049d feat(shared): add licence fields to Instance 2026-07-24 15:08:48 +01:00
mrhid6 e4d7569a1c refactor(server): correct stale org wording in messages 2026-07-24 15:07:54 +01:00
mrhid6 c3e363eccc refactor(server): finish the Org to Instance rename
Private identifiers plan 0b's naming map missed, plus the OrgOIDC model type.
No wire format, database field or route changes.
2026-07-24 15:07:06 +01:00
mrhid6 6fde319b2f feat(license): trust the production signing key 2026-07-24 15:01:03 +01:00
mrhid6 4f1fce32c1 feat(license): add lkctl for issuing licences by hand 2026-07-24 14:59:13 +01:00
mrhid6 968408b955 feat(license): add offline verification 2026-07-24 14:58:25 +01:00
mrhid6 95c5d531ae feat(license): add signing and the trusted key list 2026-07-24 14:57:52 +01:00
mrhid6 b0d8edf9b6 feat(license): add the licence payload and tier seed table 2026-07-24 14:57:21 +01:00
mrhid6 a50219c0d9 docs: correct licence scheme to ECDSA P-384 with SHA-256
hyperboloide/lk signs with ECDSA P-384 and SHA-256, not ed25519, and encodes
keys as base32 rather than hex. Probed in task 1 of the licensing-core plan.
Design is unaffected — only the prose was wrong.
2026-07-24 14:56:36 +01:00
mrhid6 f10fe61916 chore(shared): add hyperboloide/lk for licence signing
Probed the library before building against it. Two corrections to plan 1:

- PublicKey.ToB32String() returns one value, not (string, error)
- The scheme is ECDSA P-384 with SHA-256, not ed25519 as the spec and plan
  claim. Design is unaffected; the prose needs fixing.
2026-07-24 14:53:26 +01:00
mrhid6 bc1cda26f8 docs: add implementation plans for licensing-core and instance-licensing
- 2026-07-24-licensing-core.md: 7 tasks. lk payload, offline verify, the
  trusted key slice, the noSign build tag, and lkctl for issuing by hand.
- 2026-07-24-instance-licensing.md: 10 tasks. Licence on the instance
  document, cached runtime state, deny-by-default mutation gate, feature
  gates, service-layer limits, settings UI, and migration 0005 to
  grandfather existing cloud instances.

Plan 2 opens by finishing the Org to Instance rename: 18 private identifiers
survived plan 0b's sweep. Nothing functional, but the file that gains the
licence cache is one of the two still carrying the old names.

Spec index updated with plan links and shipped status.
2026-07-24 14:48:45 +01:00
mrhid6 f646ce5c47 chore(server): remove rename-rollback
Migration 0004 is complete and verified on live, so the inverse rename has
served its purpose. Reverting the release now means restoring a backup.
2026-07-24 14:40:35 +01:00
mrhid6 1eb8c79623 Merge branch 'feat/shared-module'
Server Deploy / deploy (push) Successful in 2m43s
Shared Go module extraction (spec 0a) and the Org to Instance rename (spec 0b).

- shared/ module holds the documents and provisioning rules the control plane
  and sitesvc both write; sitesvc's hand-copied duplicates are deleted
- Org becomes Instance everywhere, including the org_id field on all 17
  tenant-scoped collections, via migration 0004_org_to_instance
- cmd/rename-rollback reverses the migration
- Go images now build from the repo root so the replace directive resolves

Migration verified against a seeded legacy fixture: lossless, tenant-isolated,
idempotent, resumable and reversible. NOT yet rehearsed against a production
snapshot, which plan 0b requires before deploying.
2026-07-24 14:21:56 +01:00
mrhid6 539403cccf fix(server): drop stale indexes before renaming the tenant key
Two defects found by running migration 0004 against a seeded legacy database.

A unique index on org_id treats a missing org_id as null. Renaming the field
strips it, so the second document collided and the whole update failed:

  E11000 duplicate key error collection: instance_oidc index: org_id_1
  dup key: { org_id: null }

The index cleanup therefore has to run BEFORE the field rename, not after.
rename-rollback needs the symmetric step for instance_id, or reverting hits
the same wall.

The detection also silently matched nothing: the driver decodes an index key
document as bson.D, not bson.M, so the type assertion always failed and no
index was ever dropped. IndexKeyedOn now handles both.
2026-07-24 14:15:05 +01:00
mrhid6 50a06dfdc0 refactor(site): rename Organisation to Instance
The signup form now posts instance_name, matching sitesvc.

Also restores site/next-env.d.ts. Its /// <reference> directives had been
stripped, which removed the Next.js type environment and failed the build
with "Cannot find name 'Promise'". Same cause as the agent's missing build
constraint.
2026-07-24 14:04:52 +01:00
mrhid6 b70ccc97d4 refactor(web): rename Organisation to Instance 2026-07-24 14:01:19 +01:00
mrhid6 3f0f12b111 refactor(sitesvc): rename Org to Instance, refuse an unmigrated database
The signup form's JSON field becomes instance_name, and the pending-signup
document field with it. That collection is sitesvc-private and expires after
24 hours, so no migration is needed, but in-flight signups written before the
deploy will fail verification.
2026-07-24 14:00:11 +01:00
mrhid6 3891a2c239 feat(server): add rename-rollback command for migration 0004 2026-07-24 13:59:04 +01:00
mrhid6 4f041d2f4b refactor(server): rename Org to Instance
Adds migration 0004_org_to_instance, the ScopedCollections list, the
AssertNoScopedCollectionMissed boot check, and moves EnsureAuthIndexes into
its own file.

Two ordering constraints the rename exposed, both now enforced and commented:

- 0004 must run BEFORE EnsureAuthIndexes. The index builder creates
  instances.slug, which would create an empty instances collection and make
  0004 refuse to rename orgs onto an existing target.
- Migrations 0001 to 0003 run BEFORE 0004 and still read and write org_id, so
  they use a private legacyOrg struct rather than shared/models.
2026-07-24 13:58:41 +01:00
mrhid6 43a2fdb3a0 refactor(shared): rename Org to Instance 2026-07-24 13:54:31 +01:00
mrhid6 de0e7b7cae chore: commit go.work.sum 2026-07-24 13:53:35 +01:00
mrhid6 0226936758 fix(agent): restore the !linux build constraint on collect_other.go
Unrelated to the shared module work; pre-existing on main.

"_other" is not a GOOS suffix, so without the constraint this file compiled
on Linux alongside collect_linux.go and redeclared collect. Linux agent
builds failed, which agent-release.yml depends on.
2026-07-24 13:48:23 +01:00
mrhid6 42cd73c5b8 build: build Go images from the repo root for the shared module
Also pins the explicit shared require in both go.mod files. `go mod tidy`
in workspace mode drops it, which breaks the Docker build where no
workspace exists.
2026-07-24 13:45:45 +01:00
mrhid6 51622a922b refactor(sitesvc): use shared models and provision
Deletes internal/provision and the hand-mirrored Org and User structs. The
control plane and sitesvc now share one definition of both.
2026-07-24 13:43:58 +01:00
mrhid6 76d3111a72 refactor(server): use shared models, provision and indexes
Also removes the duplicate Slugify in stepscan.go; workflow step slugs now
use the shared definition too.
2026-07-24 13:42:44 +01:00
mrhid6 b335dc77e3 feat(shared): add EnsureCoreIndexes 2026-07-24 13:39:46 +01:00
mrhid6 51db5ab2e9 feat(shared): add CreateOrg, RollbackOrg and CreateUser
Adopts sitesvc's retry-on-duplicate-key slug loop. The control plane
previously returned an error when it lost the slug race.
2026-07-24 13:39:20 +01:00
mrhid6 d715d2150c feat(shared): add slug rules and shared document models 2026-07-24 13:38:52 +01:00
mrhid6 98687043f7 chore: scaffold shared module 2026-07-24 13:38:05 +01:00
mrhid6 bdc26e4799 docs: add implementation plans for specs 0a and 0b
- 2026-07-24-shared-module.md: 8 tasks, extract the shared Go module
- 2026-07-24-instance-rename.md: 9 tasks, Org -> Instance including the
  database field, with migration 0004 and a rollback command

No automated tests per instruction; verification is by compiler, grep, and
manual end-to-end plus production-snapshot rehearsal. Spec testing sections
updated to match.
2026-07-24 13:31:14 +01:00
mrhid6 b027ad3f7f docs: add licensing programme specs
Seven specs covering the licensing and billing programme:

- 0a shared-module: extract shared Go module, remove sitesvc duplication
- 0b instance-rename: Org -> Instance, including the database field
- 1  licensing-core: lk-signed licence payload, offline verify, CLI issuer
- 2  instance-licensing: storage, enforcement, degraded mode, settings UI
- 3  admin-backend: accounts, instances, licences, subscriptions, injection
- 4  admin-site: staff and customer portal
- 5  paddle-billing: catalog, checkout, webhooks, signup migration

Design only. No implementation.

Un-ignores docs/superpowers/ so specs are versioned.
2026-07-24 13:13:47 +01:00
mrhid6 fd32c96d86 feat: Updated site
Server Deploy / deploy (push) Successful in 2m23s
2026-07-24 11:50:11 +01:00
mrhid6 b74cd10dfc fix: Fixed bg zoffset
Server Deploy / deploy (push) Successful in 51s
2026-07-24 11:23:36 +01:00
mrhid6 460b4afb22 fix: FIxed compile error
Server Deploy / deploy (push) Successful in 1m45s
2026-07-24 11:18:14 +01:00
mrhid6 9cc9e68d90 fix: FIxed compile error
Server Deploy / deploy (push) Failing after 1m3s
2026-07-24 11:06:57 +01:00
mrhid6 accf7493e9 feat: Updated login bg
Server Deploy / deploy (push) Failing after 1m0s
2026-07-24 11:02:44 +01:00
mrhid6 f0547317f7 feat: Updated login screen text
Server Deploy / deploy (push) Successful in 49s
2026-07-24 10:52:25 +01:00
mrhid6 60f0ddecd6 fix: Fixed sidebar logo
Server Deploy / deploy (push) Successful in 1m44s
2026-07-24 10:37:53 +01:00
mrhid6 8f5aebdd63 fix: Fixed sidebar logo
Server Deploy / deploy (push) Successful in 1m33s
2026-07-24 10:36:55 +01:00
mrhid6 a6030ef62f feat: Updated logo
Server Deploy / deploy (push) Successful in 3m51s
2026-07-24 10:31:11 +01:00
mrhid6 7df79d05f1 fix: Fixes to platform page
Server Deploy / deploy (push) Successful in 1m32s
2026-07-24 10:13:33 +01:00
mrhid6 f002eab1f6 fix: Fixed compile error
Server Deploy / deploy (push) Failing after 2m51s
2026-07-24 10:01:06 +01:00
mrhid6 e798365be2 feat: Removed comments
Server Deploy / deploy (push) Failing after 1m13s
2026-07-24 09:56:54 +01:00
mrhid6 1a6cf03c03 feat: Removed comments
Server Deploy / deploy (push) Failing after 1m59s
2026-07-24 09:51:30 +01:00
mrhid6 3b52bcbeb8 updates
Server Deploy / deploy (push) Successful in 2m50s
2026-07-24 09:24:03 +01:00
mrhid6 693d59a3e2 feat: Marketing site
Server Deploy / deploy (push) Successful in 5m51s
2026-07-22 16:50:13 +01:00
mrhid6 7a3b8cb700 feat: Updated claude.md 2026-07-22 13:22:26 +01:00
mrhid6 97bc766afb feat: Remove plans 2026-07-22 13:03:19 +01:00
mrhid6 9ffae221ac fix: Fixed server install scripts
Server Deploy / deploy (push) Successful in 59s
2026-07-22 12:59:05 +01:00
mrhid6 cbb66c63f6 feat: multi-tenant SaaS — orgs, auth, per-org isolation
Server Deploy / deploy (push) Successful in 1m31s
Converts Vantage from a single-admin self-hosted app into a multi-tenant
SaaS. Org isolation is org_id row-scoping in one shared deployment and
database; each org gets a subdomain via wildcard DNS, but the hostname is
a routing hint and never an authorization boundary.

- Orgs, users and roles (owner|admin|member) with local password auth,
  first-run bootstrap, and per-org OIDC replacing the global provider
- Redis sessions carrying org and role; host/session mismatch guard
- Every tenant-scoped collection carries org_id; handlers take org from
  the session only, never from client input
- Agent gRPC path resolves org from the servers record, so agent configs
  and the shared gRPC host are unchanged
- Per-org settings and ESO read token, replacing global singletons
- Frontend: login, setup and org settings pages; org-aware AuthProvider
- Migrations 0001-0003 backfill an existing single-tenant instance

Reviewed per task plus a whole-branch and a migration-focused pass.
2026-07-22 11:13:28 +01:00
mrhid6 0d4fb896bb docs(spec): correct collection names and per-org caveats
The spec named the audit collection "audit" and the channels collection
"channels"; the code uses audit_logs and notification_channels. The
migration followed the spec, which is how it came to backfill two
collections that do not exist.
2026-07-22 11:13:19 +01:00
mrhid6 0b7e55d301 fix(server): adopt the migrated org on bootstrap; survive fresh install
Two failures found by tracing the migration path against a real upgrade.

Bootstrap orphaned the entire dataset. On upgrade, 0001 creates the
Default org and stamps every legacy document with it, but the instance
has no users, so the operator must run /auth/bootstrap to get in — and
that unconditionally created a SECOND org and put the owner in it. Every
org-scoped read then filtered on the new org, so the operator would log
into an empty Vantage while all their data sat under "default". Nothing
errored and agents kept syncing, so it presented as total data loss.
Bootstrap now adopts the sole existing org, renaming and re-slugging it,
and only creates one when no org exists. More than one org with no users
is refused rather than guessed.

Fresh installs crash-looped. Nothing creates the settings collection
before EnsureSettingsIndexes, so DropOne returned NamespaceNotFound (26),
isIndexNotFound matched only IndexNotFound (27), and that check is fatal.
The same early return also skipped index creation in the secrets and
workflow ensures.

Also: only insert the backfill org on ErrNoDocuments, so a transient read
error can't race the fatal unique slug index; run 0002 before 0003 so the
settings migration can't be pushed into its ambiguous branch; fail 0002's
ambiguous case with a remedy instead of continuing into a fatal index
build; and skip non-string ids in the owner backfill rather than aborting.
2026-07-22 10:51:42 +01:00
mrhid6 77a92787fb fix: repair migration collection names and cross-cutting scoping gaps
Findings from the final whole-branch review.

- scopedCollections named "audit" and "channels", but the code writes to
  audit_logs and notification_channels. On upgrade from single-tenant,
  legacy audit events and channels would never get org_id, becoming
  invisible to org-filtered reads while channels silently stopped firing
  — and the detection loop counted the wrong names, so the 0001 marker
  could be written having migrated nothing. Names fixed, plus migration
  0003 so an incorrectly-migrated instance converges with a fresh one.
- EnsureAuthIndexes failure is now fatal. GetUserByEmail is unscoped and
  the OIDC cross-org guard compares against whichever duplicate Mongo
  returns first, so users.email uniqueness is a security invariant, and a
  legacy collection with duplicate emails is the realistic upgrade case.
- Evict the per-org OIDC provider cache on save; rotating away from a
  compromised IdP previously had no effect until restart.
- Build the oauth2 config per request instead of mutating a shared cached
  pointer outside the mutex, which raced on RedirectURL between
  concurrent logins for the same org.
- Stamp org_id on console_sessions, incidents and monitor_rollups, the
  last collections with no tenant column. 0003 derives their org from the
  owning server/monitor rather than defaulting, so one org's console
  history and incident timeline cannot merge into another's.
- Seed default steps when an org is created, not only at boot.
- Reject an empty session OrgID at the middleware.
- Derive the app root label from APP_ROOT_LABEL instead of hardcoding
  "vantage", which silently disabled the host guard off that domain.
- Stop caching negative slug lookups, so a new org's subdomain resolves
  immediately.
2026-07-22 10:44:17 +01:00
mrhid6 aa31cd8a10 fix: validate roles, guard last owner, scope bootstrap status
Security review of e70b2f0. The UI gating was correctly backed by
RequireRole everywhere; these are the missing validation gaps behind it.

- UpdateUserRole and createOrgUser accepted any role string verbatim, so
  an admin could self-promote to owner, create an owner outright, or set
  a junk role that silently stripped a user's access. Roles are now
  whitelisted, only an owner may grant or remove the owner role, and an
  actor cannot change their own.
- Neither demote nor delete guarded the last owner, so an org could reach
  zero owners. Both now refuse when no owner would remain, returning 409.
  Self-delete rejected.
- CountUsers counted across all orgs, so a locked-out org could never
  re-bootstrap once another tenant existed, and the unauthenticated
  bootstrap-status endpoint reported instance-wide state. It now answers
  per-org on an org host, falling back to global only on the apex.
- HandleMe repeats the middleware's host/org check; it sits outside the
  middleware so it can still return its own 401.
- Post-bootstrap now sends the new owner to their org host's login page.
  The session cookie is deliberately scoped to the exact host, so the old
  redirect landed them unauthenticated.
- AuthProvider renders an error state instead of mounting the shell with
  a null user when /auth/me fails for a reason other than 401.
- api.ts unwraps {"error": ...} so these messages render as text.
2026-07-22 10:26:21 +01:00
mrhid6 e70b2f0e67 feat(web): login, first-run setup, org settings; org-aware AuthProvider
- Route group (app) holds AuthProvider + Sidebar, so /login and /setup
  render without app chrome and never mount the provider.
- AuthProvider drops the removed auth_enabled flag and exposes
  {user, org, isAdmin}.
- New login page (password + SSO), first-run setup page, and org settings
  page with a members table and the OIDC provider form.
- Settings page and the Organization nav entry are gated on role, since
  /api/settings now 403s for members.
- GET /api/org/oidc gains client_secret_set so the UI can show whether a
  secret is stored; the secret itself is still never serialized, and an
  empty submitted value still means "keep the stored one".
- Fix logout: the sidebar linked to /auth/logout with a GET, but the route
  is POST-only, so logout was 404ing.
2026-07-22 10:17:12 +01:00
mrhid6 156c5354de fix(server): close fail-open paths in monitor org checks
Review hardening on e2f5f1f. None of these were live bypasses; all were
one bad row or one new caller away from becoming one.

- Replace the empty-orgID sentinel with explicit scheduler entry points.
  The sentinel meant "skip the org check" and was keyed on a value read
  from a DB record on the agent path, so a server doc with a blank org_id
  would silently disable isolation. The exported agent-facing functions
  now reject an empty orgID outright.
- ValidateAgentToken errors when the resolved server has no org.
- UpdateMonitor's runner and channel_ids type assertions were fail-open:
  a wrong-typed value skipped validation while the $set still ran. Now a
  hard error.
- Normalise an empty runner on update to the server runner, matching
  create. Previously it matched no runner at all, so the monitor silently
  stopped being checked and stopped alerting.
- IngestResult returns an error for an unknown monitor, so probing an
  unknown ID looks the same as probing a foreign one.
2026-07-22 10:05:37 +01:00
mrhid6 e2f5f1fa8c feat(grpc): resolve org from server record for agent RPCs
Two instances of the branch's recurring bug class remained in the agent
path: a client-supplied ID accepted as data, then consumed by an
unscoped query.

- ListMonitorsForRunner filtered on `runner` alone, and `runner` is set
  by the client on monitor create/update. Org A could point a monitor at
  org B's server_id and org B's agent would fetch and execute the check.
  Now org-filtered, and `runner` is validated against the caller's org on
  create and update.
- IngestResult resolved the monitor via the unfiltered getMonitorByID
  using a monitor_id from the agent's request body, letting org A's agent
  write state and incidents into org B's monitor and fire its channels.
  Now rejects on org mismatch and on a monitor not assigned to the
  reporting agent.

The in-process scheduler passes an empty orgID as a documented sentinel
for the cross-org server-run sweep.

Install script still emits a single shared GRPC_HOST; the agent path
resolves org from the server record, never from a hostname.
2026-07-22 10:00:30 +01:00
mrhid6 4f512d01f1 fix(server): harden per-org settings migration and sweeps
Review follow-ups on e5363a6:

- MigrateSettingsOrg no longer guesses via the "default" slug. One org
  means stamp that org; zero orgs means synthesise Default; more than
  one means leave it alone and log, since guessing would hand one org
  another's SMTP config and ESO token.
- EnsureSettingsIndexes failure is now fatal. Without the unique index
  on org_id, GetSettings returns an arbitrary duplicate; without the one
  on the token hash, ResolveSecretsReadToken picks an arbitrary org.
- Name the token-hash index explicitly so it stops colliding with the
  legacy name DropOne targets, and exclude the empty string from the
  partial filter.
- Log retention: distinguish a missing run doc from a Mongo error, so a
  transient failure skips the directory rather than purging it at the
  30-day default.
- Offline sweep: fresh context per org, log-and-continue on a per-org
  error, plus a final pass for servers whose org no longer exists.
- ESO handler 401s on an empty token-derived org rather than querying
  org_id "".
2026-07-22 09:41:51 +01:00
mrhid6 e5363a64ee fix(server): per-org settings and ESO read token
The settings collection was a single global document, so every org
shared one SMTP config, alert config, retention policy and ESO read
token. GetSecretGroupDecryptedAny then flattened every org's secrets
for a group into one map, meaning any tenant's token read every
tenant's secrets.

- settings gains org_id; GetSettings/SaveSettings/RotateSecretsReadToken/
  GetWorkflowLogRetentionDays all take orgID
- VerifySecretsReadToken replaced by ResolveSecretsReadToken, which
  resolves the org from the presented token's hash; the ESO endpoint
  derives its org from the token rather than a session, since it is
  called machine-to-machine
- GetSecretGroupDecryptedAny deleted in favour of the org-scoped variant
- settings and token-rotation routes now require owner/admin
- offline sweep and log retention resolve org per server / per run
- migration 0002 stamps the legacy settings doc with the default org

Note: /api/settings now 403s for members; the web settings page needs a
matching role check.
2026-07-22 09:35:47 +01:00
mrhid6 5a701acc82 fix: Removed tests 2026-07-22 09:31:29 +01:00
mrhid6 dff3668a25 fix(server): validate cross-org resource ownership
Review of the org-scoping pass found that org_id on a query filter
protects the row you look up, but does nothing when a handler accepts a
foreign resource ID as data and a downstream unscoped query consumes it.

- AssignKey: verify key and server both belong to the org
- BuildAuthorizedKeys: resolve server first, scope assignments and keys
  to that server's org (was honouring foreign assignment rows)
- Workflows: validate TargetServerIDs on create/update and re-check at
  trigger time
- Monitor incidents/uptime handlers: gate on org-scoped GetMonitor
- GetChannels: take orgID; validate channel_ids on monitor create/update
- Secret and default-step unique indexes: scope to org_id so a second
  org no longer hits E11000
- DeleteServer/DeleteMonitor: scope cascading deletes
2026-07-22 09:28:43 +01:00
mrhid6 850aa0ed05 feat(server): org-scope service layer + handlers + org admin API
Threads org_id through every admin-facing service function (servers, keys,
assignments, secrets, workflows/steps/runs, monitors, channels, audit),
adds RequireRole middleware, and wires /api/org user + OIDC management
routes. Agent/scheduler paths keep unique-key signatures and resolve org
from the loaded record; internal-only helpers (getServerByID,
getRunByID, getMonitorByID) preserve those call sites.
2026-07-21 16:56:39 +01:00
mrhid6 d0ed9885e7 feat(auth): per-org OIDC resolver replaces global provider 2026-07-21 16:41:50 +01:00
mrhid6 ff22340561 feat(auth): host-based org resolution + session/host match guard 2026-07-21 16:38:24 +01:00
mrhid6 2038e86b53 feat(auth): local email/password login + first-run bootstrap 2026-07-21 16:36:13 +01:00
mrhid6 404214d82e fix(services): keep original step Slugify; org reuses it 2026-07-21 16:33:31 +01:00
mrhid6 01fd2e201e feat(services): org create+slug and user service with bcrypt 2026-07-21 16:30:42 +01:00
mrhid6 066095ffca feat(auth): session carries org_id/role; add context helpers 2026-07-21 16:28:06 +01:00
mrhid6 022b1ef8ec feat(server): auth indexes + default-org backfill migration 2026-07-21 16:25:37 +01:00
mrhid6 a8771a6e4d style(models): gofmt org_id field alignment 2026-07-21 16:24:16 +01:00
mrhid6 502045d3af feat(models): add org/user/org_oidc models and org_id on scoped collections 2026-07-21 16:23:14 +01:00
mrhid6 ea73fc3a18 docs(plan): saas auth + orgs implementation plan 2026-07-21 16:20:20 +01:00
mrhid6 75b86e3843 docs(spec): add org slug, host-based resolution, single gRPC endpoint 2026-07-21 16:15:29 +01:00
mrhid6 9767e18123 chore: removed old plans 2026-07-21 15:54:11 +01:00
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
228 changed files with 39508 additions and 2214 deletions
+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"
}
+21 -3
View File
@@ -23,15 +23,33 @@ jobs:
- name: Build and push server image
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/keymanager/server:latest"
docker build -t "$IMAGE" -f server/Dockerfile server/
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/server:latest"
# Root context: server depends on the shared module.
docker build -t "$IMAGE" -f server/Dockerfile .
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" \
-f web/Dockerfile web/
docker push "$IMAGE"
- name: Build and push site image
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/site:latest"
docker build \
--build-arg NEXT_PUBLIC_SITE_API="${{ vars.SITE_API_URL }}" \
--build-arg NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk" \
-t "$IMAGE" \
-f site/Dockerfile site/
docker push "$IMAGE"
- name: Build and push sitesvc image
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/sitesvc:latest"
# Root context: sitesvc depends on the shared module.
docker build -t "$IMAGE" -f sitesvc/Dockerfile .
docker push "$IMAGE"
+10 -1
View File
@@ -1,4 +1,13 @@
node_modules
dist
build
.env
.env
docs/*
!docs/superpowers/
.superpowers
installer/vantage-agent-windows-amd64.exe
installer/*.msi
installer/nssm.zip
installer/checksums-msi.txt
.next
*.tsbuildinfo
+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
+222
View File
@@ -0,0 +1,222 @@
package checker
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"time"
)
const (
TypeHTTP = "http"
TypeTCP = "tcp"
TypeICMP = "icmp"
TypeTLS = "tls"
)
type Spec struct {
Type string
URL string
Host string
Port int
Method string
ExpectedStatus int
Keyword string
TLSWarnDays int
Insecure bool
TimeoutSec int
}
type Result struct {
Up bool
LatencyMs int
Message string
CertExpiry *time.Time
}
func (s Spec) timeout() time.Duration {
t := s.TimeoutSec
if t <= 0 || t > 10 {
t = 10
}
return time.Duration(t) * time.Second
}
func Run(ctx context.Context, s Spec) Result {
switch s.Type {
case TypeHTTP:
return runHTTP(ctx, s)
case TypeTCP:
return runTCP(ctx, s)
case TypeICMP:
return runICMP(ctx, s)
case TypeTLS:
return runTLS(ctx, s)
default:
return Result{Message: "unknown check type: " + s.Type}
}
}
func runHTTP(ctx context.Context, s Spec) Result {
method := s.Method
if method == "" {
method = http.MethodGet
}
expect := s.ExpectedStatus
if expect == 0 {
expect = 200
}
client := &http.Client{Timeout: s.timeout()}
if s.Insecure {
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
}
start := time.Now()
req, err := http.NewRequestWithContext(ctx, method, s.URL, nil)
if err != nil {
return Result{Message: err.Error()}
}
resp, err := client.Do(req)
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
}
defer resp.Body.Close()
res := Result{LatencyMs: msSince(start), Up: true}
if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
exp := resp.TLS.PeerCertificates[0].NotAfter
res.CertExpiry = &exp
}
if resp.StatusCode != expect {
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: fmt.Sprintf("status %d (want %d)", resp.StatusCode, expect)}
}
if s.Keyword != "" {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if !strings.Contains(string(body), s.Keyword) {
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: "keyword not found"}
}
}
return res
}
func runTCP(ctx context.Context, s Spec) Result {
addr := net.JoinHostPort(s.Host, fmt.Sprint(s.Port))
start := time.Now()
d := net.Dialer{Timeout: s.timeout()}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
}
conn.Close()
return Result{Up: true, LatencyMs: msSince(start)}
}
func runTLS(ctx context.Context, s Spec) Result {
port := s.Port
if port == 0 {
port = 443
}
addr := net.JoinHostPort(s.Host, fmt.Sprint(port))
start := time.Now()
d := net.Dialer{Timeout: s.timeout()}
conn, err := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: s.Host})
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
}
defer conn.Close()
certs := conn.ConnectionState().PeerCertificates
if len(certs) == 0 {
return Result{LatencyMs: msSince(start), Message: "no peer certificate"}
}
exp := certs[0].NotAfter
res := Result{LatencyMs: msSince(start), CertExpiry: &exp}
warn := s.TLSWarnDays
if warn <= 0 {
warn = 14
}
remaining := time.Until(exp)
if remaining <= 0 {
res.Message = "certificate expired"
return res
}
if remaining <= time.Duration(warn)*24*time.Hour {
res.Message = fmt.Sprintf("certificate expires in %d days", int(remaining.Hours()/24))
return res
}
res.Up = true
return res
}
func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) }
func runICMP(ctx context.Context, s Spec) Result {
dst, err := net.ResolveIPAddr("ip4", s.Host)
if err != nil {
return Result{Message: err.Error()}
}
conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return Result{Message: "icmp socket: " + err.Error()}
}
defer conn.Close()
id := os.Getpid() & 0xffff
pkt := icmpEcho(id, 1)
deadline := time.Now().Add(s.timeout())
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
deadline = d
}
_ = conn.SetDeadline(deadline)
start := time.Now()
if _, err := conn.WriteTo(pkt, dst); err != nil {
return Result{Message: err.Error()}
}
reply := make([]byte, 1500)
for {
n, peer, err := conn.ReadFrom(reply)
if err != nil {
return Result{LatencyMs: msSince(start), Message: "no reply"}
}
if n < 28 || peer.String() != dst.String() {
continue
}
if reply[20] == 0 {
return Result{Up: true, LatencyMs: msSince(start)}
}
}
}
func icmpEcho(id, seq int) []byte {
b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)}
cs := icmpChecksum(b)
b[2] = byte(cs >> 8)
b[3] = byte(cs)
return b
}
func icmpChecksum(b []byte) uint16 {
var sum uint32
for i := 0; i < len(b)-1; i += 2 {
sum += uint32(b[i])<<8 | uint32(b[i+1])
}
if len(b)%2 == 1 {
sum += uint32(b[len(b)-1]) << 8
}
for sum>>16 != 0 {
sum = (sum & 0xffff) + (sum >> 16)
}
return ^uint16(sum)
}
+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"
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"
)
type streamWriter struct {
mu sync.Mutex
seq uint64
emit func(seq uint64, data []byte)
}
func (w *streamWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.emit != nil {
buf := make([]byte, len(p))
copy(buf, p)
w.emit(w.seq, buf)
w.seq++
}
return len(p), nil
}
func WorkspacePath(workspaceID string) string {
return filepath.Join(os.TempDir(), "vantage-run-"+workspaceID)
}
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult {
res := &pb.StepResult{CommandId: "", OutputEnv: map[string]string{}}
dir, err := os.MkdirTemp("", "vantage-step-")
if err != nil {
res.ExitCode = 1
res.Stderr = "create temp dir: " + err.Error()
return res
}
defer os.RemoveAll(dir)
workDir := ""
if cmd.WorkspaceId != "" {
workDir = WorkspacePath(cmd.WorkspaceId)
if err := os.MkdirAll(workDir, 0700); err != nil {
res.ExitCode = 1
res.Stderr = "create workspace: " + err.Error()
return res
}
}
envFile := filepath.Join(dir, "workflow_env")
if err := os.WriteFile(envFile, nil, 0600); err != nil {
res.ExitCode = 1
res.Stderr = "create env file: " + err.Error()
return res
}
var scriptPath string
var c *exec.Cmd
timeout := time.Duration(cmd.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 30 * time.Minute
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
switch cmd.Interpreter {
case "powershell":
scriptPath = filepath.Join(dir, "step.ps1")
if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0600); err != nil {
res.ExitCode = 1
res.Stderr = err.Error()
return res
}
shell := "pwsh"
if runtime.GOOS == "windows" {
if _, err := exec.LookPath("pwsh"); err != nil {
shell = "powershell.exe"
}
}
c = exec.CommandContext(ctx, shell, "-NoProfile", "-NonInteractive", "-File", scriptPath)
default:
scriptPath = filepath.Join(dir, "step.sh")
if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0700); err != nil {
res.ExitCode = 1
res.Stderr = err.Error()
return res
}
c = exec.CommandContext(ctx, "bash", scriptPath)
}
if workDir != "" {
c.Dir = workDir
}
c.Env = append(os.Environ(), "WORKFLOW_ENV="+envFile)
for k, v := range cmd.Env {
c.Env = append(c.Env, k+"="+v)
}
sw := &streamWriter{emit: emit}
c.Stdout = sw
c.Stderr = sw
runErr := c.Run()
if ctx.Err() == context.DeadlineExceeded {
res.ExitCode = 124
res.Stderr = "[vantage] step timed out"
} else if ee, ok := runErr.(*exec.ExitError); ok {
res.ExitCode = ee.ExitCode()
} else if runErr != nil {
res.ExitCode = 1
res.Stderr = "[vantage] " + runErr.Error()
}
res.OutputEnv = parseEnvFile(envFile)
return res
}
func parseEnvFile(path string) map[string]string {
out := map[string]string{}
f, err := os.Open(path)
if err != nil {
return out
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := sc.Text()
i := strings.IndexByte(line, '=')
if i <= 0 {
continue
}
out[line[:i]] = line[i+1:]
}
return out
}
+52 -7
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
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
}
// 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) ReportUpdates(serverID, agentToken string, updates []pb.PackageUpdate) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := c.client.ReportUpdates(ctx, &pb.ReportUpdatesRequest{
ServerId: serverID,
AgentToken: agentToken,
Updates: updates,
})
return err
}
func (c *Client) ReportInventory(report *pb.InventoryReport) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := c.client.ReportInventory(ctx, report)
return err
}
func (c *Client) SyncMonitors(serverID, agentToken string) ([]pb.MonitorSpec, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := c.client.SyncMonitors(ctx, &pb.SyncMonitorsRequest{ServerId: serverID, AgentToken: agentToken})
if err != nil {
return nil, err
}
return resp.Monitors, nil
}
func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckResult) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := c.client.ReportChecks(ctx, &pb.ReportChecksRequest{ServerId: serverID, AgentToken: agentToken, Results: results})
return err
}
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
return c.client.CommandStream(ctx)
}
-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 @@
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"`
}
type PackageUpdate struct {
Name string `json:"name"`
CurrentVersion string `json:"current_version,omitempty"`
NewVersion string `json:"new_version"`
}
type ReportUpdatesRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Updates []PackageUpdate `json:"updates"`
}
type ReportUpdatesResponse struct{}
type CPUReport struct {
Model string `json:"model,omitempty"`
Cores int `json:"cores,omitempty"`
UsagePct float64 `json:"usage_pct"`
Load1 float64 `json:"load1,omitempty"`
}
type MemReport struct {
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
}
type PartitionReport struct {
Device string `json:"device"`
Mountpoint string `json:"mountpoint"`
Fstype string `json:"fstype,omitempty"`
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
}
type InventoryReport struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
IncludeStatic bool `json:"include_static"`
CPU *CPUReport `json:"cpu,omitempty"`
Memory *MemReport `json:"memory,omitempty"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
}
type InventoryReportResponse struct{}
type MonitorSpec struct {
MonitorId string `json:"monitor_id"`
Type string `json:"type"`
URL string `json:"url,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Method string `json:"method,omitempty"`
ExpectedStatus int `json:"expected_status,omitempty"`
Keyword string `json:"keyword,omitempty"`
TLSWarnDays int `json:"tls_warn_days,omitempty"`
Insecure bool `json:"insecure,omitempty"`
IntervalSec int `json:"interval_sec"`
Retries int `json:"retries"`
}
type SyncMonitorsRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
}
type SyncMonitorsResponse struct {
Monitors []MonitorSpec `json:"monitors,omitempty"`
}
type CheckResult struct {
MonitorId string `json:"monitor_id"`
Up bool `json:"up"`
LatencyMs int `json:"latency_ms"`
Message string `json:"message,omitempty"`
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
}
type ReportChecksRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Results []CheckResult `json:"results,omitempty"`
}
type ReportChecksResponse struct{}
type ApplyUpdatesCmd struct{}
type 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"`
}
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 string `json:"workspace_id,omitempty"`
}
type StepResult struct {
CommandId string `json:"command_id"`
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
OutputEnv map[string]string `json:"output_env,omitempty"`
}
type StepOutputChunk struct {
CommandId string `json:"command_id"`
Seq uint64 `json:"seq"`
Data []byte `json:"data,omitempty"`
Eof bool `json:"eof,omitempty"`
}
type Vantage_CommandStreamClient interface {
Send(*AgentMessage) error
Recv() (*ServerCommand, error)
grpc.ClientStream
}
type vantageCommandStreamClient struct {
grpc.ClientStream
}
func (c *vantageCommandStreamClient) Send(m *AgentMessage) error {
return c.ClientStream.SendMsg(m)
}
func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
m := new(ServerCommand)
if err := c.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type Vantage_CommandStreamServer interface {
Send(*ServerCommand) error
Recv() (*AgentMessage, error)
grpc.ServerStream
}
type keyManagerCommandStreamServer struct {
grpc.ServerStream
}
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
return s.ServerStream.SendMsg(m)
}
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
m := new(AgentMessage)
if err := s.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type 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 @@
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())
for i, v := range fields[1:] {
n, _ := strconv.ParseUint(v, 10, 64)
total += n
if i == 3 {
idle = n
}
}
}
return
}
func cpuUsage() float64 {
i1, t1 := cpuSample()
time.Sleep(100 * time.Millisecond)
i2, t2 := cpuSample()
dt := float64(t2 - t1)
if dt <= 0 {
return 0
}
return (1 - float64(i2-i1)/dt) * 100
}
func load1() float64 {
fields := strings.Fields(readProc("/proc/loadavg"))
if len(fields) > 0 {
v, _ := strconv.ParseFloat(fields[0], 64)
return v
}
return 0
}
func meminfo() (total, avail, swapTotal, swapFree uint64) {
f, err := os.Open("/proc/meminfo")
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 2 {
continue
}
kb, _ := strconv.ParseUint(fields[1], 10, 64)
b := kb * 1024
switch strings.TrimSuffix(fields[0], ":") {
case "MemTotal":
total = b
case "MemAvailable":
avail = b
case "SwapTotal":
swapTotal = b
case "SwapFree":
swapFree = b
}
}
return
}
func cpuStatic() (model string, cores int) {
f, err := os.Open("/proc/cpuinfo")
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "processor") {
cores++
} else if strings.HasPrefix(line, "model name") && model == "" {
if i := strings.Index(line, ":"); i >= 0 {
model = strings.TrimSpace(line[i+1:])
}
}
}
return
}
func kernel() string {
return strings.TrimSpace(readProc("/proc/sys/kernel/osrelease"))
}
func partitions() []pb.PartitionReport {
allowed := map[string]bool{"ext4": true, "xfs": true, "btrfs": true, "zfs": true, "vfat": true, "ntfs": true, "ext3": true}
f, err := os.Open("/proc/mounts")
if err != nil {
return nil
}
defer f.Close()
var out []pb.PartitionReport
seen := map[string]bool{}
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 3 || !allowed[fields[2]] || seen[fields[1]] {
continue
}
seen[fields[1]] = true
var st syscall.Statfs_t
if syscall.Statfs(fields[1], &st) != nil {
continue
}
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
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !linux
// Inventory collection is Linux-only. This no-op stands in everywhere else.
//
// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so
// without it this file compiles on Linux too and collides with collect_linux.go.
package inventory
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
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"
func Collect(includeStatic bool) *pb.InventoryReport {
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
collect(r, includeStatic)
return r
}
+19 -19
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)
@@ -96,16 +96,16 @@ func fingerprint(pubKey string) string {
return "MD5:" + strings.Join(pairs, ":")
}
// KeyGenOptions controls how ssh-keygen is invoked.
type KeyGenOptions struct {
KeyType string // ed25519 (default), rsa, ecdsa
KeySize int // bits; used for rsa and ecdsa
Passphrase string // empty = no passphrase
Comment string // embedded in the public key
KeyType string
KeySize int
Passphrase string
Comment string
}
// GenerateKeyPair generates an SSH keypair and returns the public key.
// The private key is written to keyPath; keyPath+".pub" holds the public key.
func GenerateKeyPair(keyPath string, opts KeyGenOptions) (string, error) {
if err := os.MkdirAll(filepath.Dir(keyPath), 0700); err != nil {
return "", err
@@ -139,8 +139,8 @@ func GenerateKeyPair(keyPath string, opts KeyGenOptions) (string, error) {
return strings.TrimSpace(string(pubData)), nil
}
// AddSSHIdentity writes an IdentityFile entry for keyPath into the managed
// keymanager.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)
@@ -150,7 +150,7 @@ func AddSSHIdentity(keyPath string) error {
return err
}
// Read existing managed config (it may not exist yet).
var existing string
data, err := os.ReadFile(managedConfigPath)
if err != nil && !os.IsNotExist(err) {
@@ -161,7 +161,7 @@ func AddSSHIdentity(keyPath string) error {
line := "IdentityFile " + keyPath
for _, l := range strings.Split(existing, "\n") {
if strings.TrimSpace(l) == line {
return nil // already present
return nil
}
}
@@ -176,7 +176,7 @@ func AddSSHIdentity(keyPath string) error {
return nil
}
// RemoveSSHIdentity removes the IdentityFile entry for keyPath from the managed config.
func RemoveSSHIdentity(keyPath string) error {
data, err := os.ReadFile(managedConfigPath)
if os.IsNotExist(err) {
@@ -204,9 +204,9 @@ func RemoveSSHIdentity(keyPath string) error {
return nil
}
// ensureIncludeDirective adds "Include /root/.ssh/keymanager.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 {
data, err := os.ReadFile(sshConfigPath)
if err != nil && !os.IsNotExist(err) {
@@ -215,11 +215,11 @@ func ensureIncludeDirective() error {
for _, l := range strings.Split(string(data), "\n") {
if strings.TrimSpace(l) == includeDirective {
return nil // already present
return nil
}
}
// Prepend the Include directive so it takes effect before any Host blocks.
updated := includeDirective + "\n" + string(data)
if err := os.WriteFile(sshConfigPath, []byte(updated), 0600); err != nil {
return fmt.Errorf("write %s: %w", sshConfigPath, err)
+166
View File
@@ -0,0 +1,166 @@
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"
)
const syncInterval = 30 * time.Second
type runner struct {
intervalSec int
cancel context.CancelFunc
}
func Run(ctx context.Context, cfg *config.Config) {
active := map[string]*runner{}
var mu sync.Mutex
results := make(chan pb.CheckResult, 64)
go reporter(ctx, cfg, results)
syncOnce := func() {
specs, err := fetchSpecs(cfg)
if err != nil {
log.Printf("monitors: sync: %v", err)
return
}
want := map[string]pb.MonitorSpec{}
for _, s := range specs {
want[s.MonitorId] = s
}
mu.Lock()
defer mu.Unlock()
for id, r := range active {
s, ok := want[id]
if !ok || s.IntervalSec != r.intervalSec {
r.cancel()
delete(active, id)
}
}
for id, s := range want {
if _, ok := active[id]; ok {
continue
}
rctx, cancel := context.WithCancel(ctx)
active[id] = &runner{intervalSec: s.IntervalSec, cancel: cancel}
go runSpec(rctx, s, results)
}
}
syncOnce()
t := time.NewTicker(syncInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
syncOnce()
}
}
}
func fetchSpecs(cfg *config.Config) ([]pb.MonitorSpec, error) {
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
return nil, err
}
defer client.Close()
return client.SyncMonitors(cfg.ServerID, cfg.AgentToken)
}
func runSpec(ctx context.Context, s pb.MonitorSpec, out chan<- pb.CheckResult) {
interval := time.Duration(s.IntervalSec) * time.Second
if interval <= 0 {
interval = 60 * time.Second
}
spec := checker.Spec{
Type: s.Type,
URL: s.URL,
Host: s.Host,
Port: s.Port,
Method: s.Method,
ExpectedStatus: s.ExpectedStatus,
Keyword: s.Keyword,
TLSWarnDays: s.TLSWarnDays,
Insecure: s.Insecure,
TimeoutSec: s.IntervalSec,
}
run := func() {
res := checker.Run(ctx, spec)
cr := pb.CheckResult{MonitorId: s.MonitorId, Up: res.Up, LatencyMs: res.LatencyMs, Message: res.Message}
if res.CertExpiry != nil {
cr.CertExpiryUnix = res.CertExpiry.Unix()
}
select {
case out <- cr:
case <-ctx.Done():
}
}
run()
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
run()
}
}
}
func reporter(ctx context.Context, cfg *config.Config, in <-chan pb.CheckResult) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
var batch []pb.CheckResult
flush := func() {
if len(batch) == 0 {
return
}
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
log.Printf("monitors: report dial: %v", err)
batch = nil
return
}
if err := client.ReportChecks(cfg.ServerID, cfg.AgentToken, batch); err != nil {
log.Printf("monitors: report: %v", err)
}
client.Close()
batch = nil
}
for {
select {
case <-ctx.Done():
flush()
return
case r := <-in:
batch = append(batch, r)
if len(batch) >= 32 {
flush()
}
case <-t.C:
flush()
}
}
}
+235 -25
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 {
@@ -28,7 +34,7 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
}
defer client.Close()
// Register if we have a pre-reg token
if cfg.PreRegToken != "" {
log.Println("registering with server...")
hostname, _ := os.Hostname()
@@ -55,16 +61,25 @@ func Run(ctx context.Context, cfg *config.Config, version string) error {
}
if cfg.AgentToken == "" {
return fmt.Errorf("no agent token available registration required")
return fmt.Errorf("no agent token available registration required")
}
// Start the command stream alongside the poll loop.
go runCommandStream(ctx, cfg)
go runUpdateCheck(ctx, cfg)
go runInventory(ctx, cfg)
go monitors.Run(ctx, cfg)
ticker := time.NewTicker(cfg.PollInterval)
defer ticker.Stop()
// Run immediately on startup
if err := poll(client, cfg, version); err != nil {
log.Printf("poll error: %v", err)
}
@@ -87,6 +102,10 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
return fmt.Errorf("SyncKeys: %w", err)
}
if runtime.GOOS != "linux" {
return nil
}
current, err := keys.ReadAuthorizedKeys()
if err != nil {
return fmt.Errorf("read authorized_keys: %w", err)
@@ -104,8 +123,8 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
return nil
}
// runCommandStream maintains a persistent bidirectional stream with the server
// for instant command delivery. Reconnects with exponential backoff on failure.
func runCommandStream(ctx context.Context, cfg *config.Config) {
backoff := time.Second
const maxBackoff = 2 * time.Minute
@@ -158,6 +177,16 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
log.Println("command stream connected")
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 +202,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
_ = 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()
}
}
}
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)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
tick := 0
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
tick++
report(tick%30 == 0)
}
}
}
func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) {
log.Printf("applying OS updates (cmd=%s)…", cmd.CommandId)
if err := updates.ApplyAll(); err != nil {
log.Printf("OS upgrade failed (cmd=%s): %v", cmd.CommandId, err)
return
}
log.Printf("OS updates applied successfully (cmd=%s)", cmd.CommandId)
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
return
}
defer client.Close()
_ = client.ReportUpdates(cfg.ServerID, cfg.AgentToken, nil)
}
func handleCleanupWorkspace(cmd *pb.ServerCommand) {
id := cmd.CleanupWorkspace.WorkspaceId
dir := agentexec.WorkspacePath(id)
if err := os.RemoveAll(dir); err != nil {
log.Printf("cleanup workspace %s failed (cmd=%s): %v", dir, cmd.CommandId, err)
return
}
log.Printf("removed run workspace %s (cmd=%s)", dir, cmd.CommandId)
}
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,28 +357,33 @@ 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"
arch := runtime.GOARCH
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
}
// Download and verify checksum
checksumData, err := httpGetBytes(checksumURL)
if err != nil {
log.Printf("update checksum fetch failed (cmd=%s): %v", cmd.CommandId, err)
return
}
if err := verifyChecksum(tmpBin, fmt.Sprintf("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,17 +393,58 @@ 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()
}
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)
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 {
resp, err := http.Get(url) //nolint:gosec
resp, err := http.Get(url)
if err != nil {
return err
}
@@ -252,7 +462,7 @@ func downloadFile(url, dest string) error {
}
func httpGetBytes(url string) ([]byte, error) {
resp, err := http.Get(url) //nolint:gosec
resp, err := http.Get(url)
if err != nil {
return nil, err
}
@@ -290,7 +500,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,
@@ -344,7 +554,7 @@ func localIP() string {
return ""
}
// GenerateAndUpload generates an SSH keypair and uploads the public key to the server.
func GenerateAndUpload(cfg *config.Config, label string) error {
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
@@ -352,7 +562,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
+238
View File
@@ -0,0 +1,238 @@
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 ""
}
func CheckAvailable() ([]PackageUpdate, error) {
switch detectPM() {
case "apt":
return checkApt()
case "dnf":
return checkDnfYum("dnf")
case "yum":
return checkDnfYum("yum")
case "pacman":
return checkPacman()
case "zypper":
return checkZypper()
case "apk":
return checkApk()
default:
return nil, nil
}
}
func ApplyAll() error {
switch detectPM() {
case "apt":
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
return err
}
return exec.CommandContext(ctx, "apt-get", "upgrade", "-y").Run()
case "dnf":
return exec.Command("dnf", "upgrade", "-y").Run()
case "yum":
return exec.Command("yum", "upgrade", "-y").Run()
case "pacman":
return exec.Command("pacman", "-Syu", "--noconfirm").Run()
case "zypper":
return exec.Command("zypper", "update", "-y").Run()
case "apk":
return exec.Command("apk", "upgrade").Run()
default:
return nil
}
}
func checkApt() ([]PackageUpdate, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run()
out, err := exec.Command("apt", "list", "--upgradable").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.Contains(line, "[upgradable from:") {
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
name := strings.SplitN(parts[0], "/", 2)[0]
newVer := parts[1]
oldVer := ""
if idx := strings.Index(line, "upgradable from: "); idx != -1 {
rest := line[idx+len("upgradable from: "):]
oldVer = strings.TrimSuffix(strings.TrimSpace(rest), "]")
}
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
}
return updates, nil
}
func checkDnfYum(pm string) ([]PackageUpdate, error) {
cmd := exec.Command(pm, "check-update")
out, err := cmd.Output()
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
err = nil
}
if err != nil {
return nil, err
}
var updates []PackageUpdate
pastHeader := false
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !pastHeader {
if strings.TrimSpace(line) == "" {
pastHeader = true
}
continue
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
name := strings.SplitN(parts[0], ".", 2)[0]
updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]})
}
return updates, nil
}
func checkPacman() ([]PackageUpdate, error) {
out, _ := exec.Command("pacman", "-Qu").Output()
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
parts := strings.Fields(scanner.Text())
if len(parts) < 4 {
continue
}
updates = append(updates, PackageUpdate{Name: parts[0], CurrentVersion: parts[1], NewVersion: parts[3]})
}
return updates, nil
}
func checkZypper() ([]PackageUpdate, error) {
out, err := exec.Command("zypper", "list-updates").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") {
continue
}
parts := strings.Split(line, "|")
if len(parts) < 5 {
continue
}
updates = append(updates, PackageUpdate{
Name: strings.TrimSpace(parts[2]),
CurrentVersion: strings.TrimSpace(parts[3]),
NewVersion: strings.TrimSpace(parts[4]),
})
}
return updates, nil
}
func checkApk() ([]PackageUpdate, error) {
out, err := exec.Command("apk", "list", "--upgradable").Output()
if err != nil {
return nil, err
}
var updates []PackageUpdate
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if !strings.Contains(line, "[upgradable") {
continue
}
parts := strings.Fields(line)
if len(parts) < 1 {
continue
}
pkgVer := parts[0]
name := apkName(pkgVer)
newVer := apkVersion(pkgVer)
oldVer := ""
if idx := strings.Index(line, "upgradable from:"); idx != -1 {
rest := strings.TrimSpace(line[idx+len("upgradable from:"):])
rest = strings.TrimSuffix(rest, "]")
oldVer = apkVersion(strings.TrimSpace(rest))
}
updates = append(updates, PackageUpdate{Name: name, CurrentVersion: oldVer, NewVersion: newVer})
}
return updates, nil
}
func apkName(pkgVer string) string {
parts := strings.Split(pkgVer, "-")
var name []string
for _, p := range parts {
if len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
break
}
name = append(name, p)
}
return strings.Join(name, "-")
}
func apkVersion(pkgVer string) string {
parts := strings.Split(pkgVer, "-")
var ver []string
inVer := false
for _, p := range parts {
if !inVer && len(p) > 0 && p[0] >= '0' && p[0] <= '9' {
inVer = true
}
if inVer {
ver = append(ver, p)
}
}
return strings.Join(ver, "-")
}
+348 -240
View File
@@ -1,181 +1,287 @@
# 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.
A self-hosted, multi-tenant infrastructure control plane. It started as SSH key management and has grown into fleet management: SSH key assignment, workflow/script execution, service monitoring, a secrets vault, a browser console (SSH/RDP/VNC), and OS update management.
A central server (Go + Next.js + MongoDB + Redis) drives a lightweight Go agent installed on each managed server. Agents poll over gRPC and also hold a bidirectional command stream for push-style commands.
---
## Architecture Overview
```
┌─────────────────────────────────┐
│ Next.js Frontend
- Upload/manage keys
- Add servers (install script)
│ - Assign/revoke per server │
└────────────┬────────────────────┘
│ REST
┌────────────▼────────────────────┐
Go Backend
- REST API for frontend
- gRPC server for agents
- MongoDB
└────────────────────────────────┘
│ gRPC (TLS)
┌────────────────────────────────┐
│ Go Agent (per server)
- Polls every 30s
- Rewrites authorized_keys │
- Can generate SSH keypairs
└─────────────────────────────────┘
┌──────────────────────────────────────────────
│ Next.js 16 Frontend (web, :3000)
servers · keys · workflows · monitors
secrets · audit · console · settings
└───────────────┬──────────────────────────────┘
│ REST + cookie session
┌───────────────▼──────────────────────────────┐
│ Go Backend (server) │
:8080 REST (gin) :9090 gRPC (agents)
MongoDB (state) · Redis (sessions)
monitor scheduler · workflow runner
guacd tunnel proxy for browser console
└───────────────┬──────────────────────────────┘
│ gRPC (TLS) — outbound from agent only
┌───────────────▼──────────────────────────────┐
│ Go Agent (per server, Linux + Windows)
polls SyncKeys · CommandStream
rewrites authorized_keys (Linux only)
runs workflow steps · monitors · inventory
└──────────────────────────────────────────────
```
Multi-tenancy: every domain document carries `org_id`, and every service query is scoped by it. Org is resolved from the session, and optionally cross-checked against the request host (`<slug>.vantage.<tld>`).
---
## Repository Structure
```
keymanager/
vantage/
├── agent/
│ ├── cmd/main.go
│ ├── cmd/main.go # flags: -generate-key
│ └── internal/
│ ├── config/
│ ├── grpc/
│ ├── keys/
── sync/
│ ├── checker/ # monitor check execution
│ ├── config/ # config.yaml load/save
│ ├── exec/ # workflow step execution
── grpc/ # client + generated pb
│ ├── inventory/ # CPU/mem/disk collection (linux/other)
│ ├── keys/ # authorized_keys read/diff/write
│ ├── monitors/ # agent-run monitor loop
│ ├── sync/ # poll loop + command stream
│ └── updates/ # OS package update check/apply
├── server/
│ ├── cmd/main.go
│ └── internal/
│ ├── api/ # REST handlers for Next.js
│ ├── grpc/ # gRPC server implementation
│ ├── models/ # MongoDB models
── services/
├── keys.go
├── servers.go
└── sync.go # builds desired state per server
├── web/
├── app/
│ └── components/
├── proto/
── keymanager/v1/keymanager.proto
├── deploy/
── docker-compose.yml
│ └── agent.service
└── .gitea/
── workflows/
├── agent-release.yml
└── server-deploy.yml
│ ├── api/ # REST handlers
│ ├── auth/ # local, OIDC, session, middleware, orghost
│ ├── checker/ # server-run monitor checks
── db/ # mongo connect + Col()
├── grpc/ # gRPC server + generated pb
├── models/ # MongoDB documents
├── monitorsched/ # server-side monitor scheduler
│ ├── notify/ # smtp, http, templating, dispatch
└── services/ # business logic + migrations
├── web/ # the application UI (authenticated)
│ ├── app/(app)/ # authed routes
── app/login, app/setup # unauthed routes
│ ├── components/ # ui/, workflows/, monitors/, Sidebar
── lib/ # api client, guac console, query client
├── site/ # public marketing site
│ ├── app/ # one directory per route
── components/ # Nav, Footer, Logo, InstrumentPanel, forms
│ ├── assets/ # image sources, not served
│ └── Dockerfile # same shape as web/: standalone, node, 3000
├── sitesvc/ # public forms: contact mail + signup
│ ├── cmd/main.go
│ └── internal/
│ ├── api/ # contact, signup, verify
│ ├── mail/ # SMTP
│ ├── models/ # mirrors server org/user + pending signup
│ ├── provision/ # slug rules mirrored from the control plane
│ └── store/ # mongo: pending signups, org/user creation
├── proto/vantage/v1/vantage.proto
├── installer/ # Windows: setup.ps1, nssm.exe, WiX .wxs
├── deploy/ # docker-compose.yml, agent.service
└── .gitea/workflows/ # agent-release.yml, server-deploy.yml
```
---
## Subsystems
### SSH keys
Upload a public key, assign it per server, revoke softly. The agent diffs desired vs on-disk state and rewrites `/root/.ssh/authorized_keys` atomically. Keys can also be generated _on_ a server by the agent; the private half can optionally be uploaded and is stored AES-256-GCM encrypted.
### Workflows
A library of reusable **steps** (bash or PowerShell scripts with declared inputs, outputs, and secret refs) composed into **workflows** targeting a set of servers. Running one snapshots the resolved steps into a `WorkflowRun`, then dispatches `RunStepCmd` over the agent command stream. Step stdout/stderr streams back as `StepOutputChunk` and is written to a log file on disk; the UI streams it live. Steps support `on_failure: stop|continue|retry`, per-run env passed between steps via `output_env`, and a per-run workspace directory the agent cleans up at the end.
Default steps are seeded per org at boot (`SeedDefaultSteps`). Logs are swept by retention (`workflow_log_retention_days`; nil = 30 days, 0 = forever).
### Monitors
HTTP, TCP, ICMP and TLS checks. Each monitor has a `runner`: `"server"` (executed by the server-side scheduler) or a `server_id` (pushed to that agent, which runs it locally and reports results). Consecutive failures beyond `retries` flip state to `down`, open an `Incident`, and notify. Hourly `Rollup` documents back the uptime graphs.
### Notification channels
Per-org outbound destinations: `webhook`, `smtp`, `discord`, `slack`, `telegram`. Monitors reference channels by ID. Channels are testable from the UI.
### Secrets vault
Key/value pairs grouped by name, encrypted at rest with AES-256-GCM. Consumed two ways: referenced by workflow steps via `secret_refs` (injected as env at execution), and read by Kubernetes External Secrets Operator via `GET /api/secrets/:group/values` using a bearer token whose SHA-256 hash is stored in settings.
### Browser console
`POST /api/console/connect` mints a one-time session token; `GET /api/console/tunnel` upgrades to a WebSocket and proxies to **guacd** (Apache Guacamole daemon) using `github.com/wwt/guac`. SSH connections authenticate with a stored private key; RDP/VNC credentials are encrypted, single-use, and consumed when the tunnel opens.
### Inventory and OS updates
Agents report CPU/memory/swap/partitions/kernel — metrics every 30s, full static snapshot every 15 min. They also check for pending OS package updates hourly and can apply them on command (`ApplyUpdatesCmd`).
### Agent self-update
`UpdateAgentCmd` carries a target version and Gitea base URL; the agent downloads and replaces itself.
### Marketing site and sitesvc
`site/` is a separate Next.js app built exactly like `web/``output: "standalone"`, run by Node in a `node:26-alpine` image, listening on `3000` and published as `3001`. Both of its forms post to `sitesvc`; the control plane is not involved and has no public signup endpoint.
`sitesvc/` (port `8082`) owns both flows end to end:
| Form | Endpoint | Effect |
| ------------------- | ------------------------- | ----------------------------------------------------------------------- |
| Contact | `POST /api/contact` | Emails `support@hostxtra.co.uk`, `Reply-To` the sender. Nothing stored. |
| Create organisation | `POST /api/signup` | Records a pending signup and emails a verification link. |
| Verification link | `GET /api/verify?token=…` | Creates the org and its owner, then redirects to the org's sign-in page (`APP_LOGIN_URL` with `{slug}` filled in). |
All three are deliberately **excluded from the self-hosted deployment**: `deploy/docker-compose.yml` mentions none of them, and they live in `deploy/docker-compose.site.yml` instead.
```bash
# self-hosted install — no marketing site, no sitesvc
docker compose up -d
# vantage.hostxtra.co.uk — control plane plus the public site
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d
```
### Signup and verification
**Nothing is written to `orgs` or `users` until the emailed link is opened.** A signup lands in sitesvc's own `site_pending_signups` collection holding the org name, the address, and the password already bcrypt-hashed at cost 12. The consequence is worth stating: an address nobody controls can never occupy an email, hold an organisation slug, or produce an account that can sign in. It also means the control plane's login path needs no concept of "unverified".
- The token is 32 random bytes; only its **SHA-256 hash** is stored, so a leaked database yields no working links.
- `Verify` deletes the pending record **atomically before provisioning** (`FindOneAndDelete`), so a double-clicked link cannot create two organisations — the second delete matches nothing.
- Links expire after 24 hours, and a **TTL index** lets Mongo drop abandoned signups so password hashes do not linger.
- Re-submitting the form for the same address replaces the previous pending record, so only the newest link works.
- If the owner insert fails after the org is created, the org is rolled back rather than stranded holding a slug. The rollback refuses to touch an org that has users.
- Rate limited to 3 signups per client IP per hour, plus a honeypot field.
### The one piece of duplicated logic
`sitesvc/internal/provision` and `sitesvc/internal/models` mirror the control plane's slug rules, reserved names, bcrypt cost and document shapes. They are duplicated rather than imported because sitesvc is a separate module that deliberately does not depend on the server.
**Nothing enforces the match automatically.** If the control plane's `Slugify`, `reservedSlugs`, `CreateOrg` or `CreateUser` change, update `sitesvc/internal/provision` in the same commit — a divergence would provision tenants under rules the app does not agree with.
sitesvc also (re)declares the unique indexes on `users.email` and `orgs.slug` at boot so it does not depend on the server having started first. Creating an existing index is a no-op.
---
## Auth and Orgs
- **Bootstrap** — first run has no users. `GET /auth/bootstrap-status` drives `/setup`, `POST /auth/bootstrap` creates the first org plus its owner.
- **Local auth** — email + password (bcrypt), `POST /auth/login`.
- **OIDC** — configured _per org_ (`org_oidc`), issuer + client ID + encrypted client secret. `/auth/oidc/start``/auth/oidc/callback`.
- **Sessions** — opaque 32-byte hex ID in the `km_session` cookie, session body stored in Redis with a 24h TTL.
- **Roles** — `owner`, `admin`, `member`. `/api/settings` and `/api/org/*` require owner or admin.
- **Host/org guard** — `APP_ROOT_LABEL` (default `vantage`) defines the app root label. A request to `<slug>.vantage.<tld>` resolves that org from the slug and rejects sessions belonging to a different one. Org lookups are cached for 60s.
Unique indexes on user email and org slug are a **security property**, not an optimisation: `GetUserByEmail` does an unscoped `FindOne`, so duplicates would let the OIDC cross-org guard compare against an arbitrary user. Same for duplicate settings docs and duplicate ESO token hashes.
---
## gRPC API
```protobuf
syntax = "proto3";
package keymanager.v1;
service KeyManager {
rpc Register(RegisterRequest) returns (RegisterResponse);
rpc SyncKeys(SyncRequest) returns (SyncResponse);
rpc UploadGeneratedKey(UploadKeyRequest) returns (UploadKeyResponse);
}
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;
}
message SyncResponse {
repeated string public_keys = 1; // full authorized_keys lines
}
message UploadKeyRequest {
string server_id = 1;
string agent_token = 2;
string public_key = 3;
string label = 4;
}
message UploadKeyResponse {
string key_id = 1;
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);
rpc CommandStream(stream AgentMessage) returns (stream ServerCommand);
}
```
No streaming — polling only. Poll interval: **30 seconds**.
`CommandStream` is the only streaming RPC: the agent authenticates once with `AgentReady`, then the server pushes `ServerCommand`s and the agent replies with `CommandResult`, `StepResult`, or `StepOutputChunk`.
`ServerCommand` variants: `GenerateKeyCmd`, `DeleteKeyCmd`, `UpdateAgentCmd`, `ApplyUpdatesCmd`, `RunStepCmd`, `CleanupWorkspaceCmd`.
Key-state polling stays on the 30s `SyncKeys` interval. Full message definitions live in `proto/vantage/v1/vantage.proto`.
---
## REST API
Unauthenticated:
```
GET /install /install.ps1 # dynamic agent install scripts
GET /update /update.ps1
GET /auth/bootstrap-status
POST /auth/bootstrap /auth/login /auth/logout
GET /auth/me /auth/oidc/start /auth/oidc/callback
GET /api/secrets/:group/values # bearer token (ESO)
```
Session-authed under `/api`:
```
servers GET,POST /servers · GET,POST /servers/new · GET,DELETE /servers/:id
POST /servers/:id/{generate-key,update-agent,apply-updates}
keys GET,POST /keys · GET,DELETE /keys/:id · GET /keys/:id/private-key
POST /keys/:id/assign · DELETE /keys/:id/assign/:serverId
workflows GET,POST /steps · PUT,DELETE /steps/:id · GET /steps/:id/export
POST /steps/{import,seed-defaults,parse} · GET /steps/usage
GET,POST /workflows · GET,PUT,DELETE /workflows/:id
POST /workflows/:id/run · GET /workflows/:id/runs
GET /runs/:runId · POST /runs/:runId/cancel
GET /runs/:runId/servers/:serverId/logs[/stream]
monitors GET,POST /monitors · GET,PUT,DELETE /monitors/:id
GET /monitors/:id/{incidents,uptime}
channels GET,POST /channels · PUT,DELETE /channels/:id · POST /channels/:id/test
secrets GET,POST /secrets · GET,PUT,DELETE /secrets/:group
POST /secrets/:group/reveal · DELETE /secrets/:group/:key
console POST /console/connect · GET /console/tunnel (websocket)
audit GET /audit
agent GET /agent/latest-version
settings GET,PUT /settings · POST /settings/secrets-token (owner|admin)
org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users/:id
GET,PUT /org/oidc (owner|admin)
```
---
## MongoDB Collections
### `servers`
`servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations`
```json
{
"_id": "ObjectId",
"server_id": "uuid",
"hostname": "proxmox-node-1",
"ip_address": "10.10.10.5",
"os_info": "Ubuntu 24.04",
"pre_reg_token": "abc123",
"pre_reg_expires": "ISODate",
"agent_token_hash": "sha256...",
"status": "pending|active|offline",
"last_seen": "ISODate",
"created_at": "ISODate"
}
```
Every document except `migrations` carries `org_id`. Struct definitions are the source of truth — see `server/internal/models/`.
- `pre_reg_token` is cleared after the agent successfully calls `Register()`
- `agent_token_hash` stores SHA-256 of the token — never plaintext
- `status` transitions: `pending``active` on first `Register()`, `offline` if last_seen exceeds threshold
`site_pending_signups` is written only by sitesvc and holds unverified signups; the control plane neither reads nor knows about it.
### `keys`
Notes that are not obvious from the structs:
```json
{
"_id": "ObjectId",
"key_id": "uuid",
"label": "dom-macbook",
"public_key": "ssh-ed25519 AAAA...",
"fingerprint": "SHA256:...",
"source": "uploaded|generated",
"generated_by_server_id": "uuid",
"created_at": "ISODate"
}
```
- `servers.agent_token_hash` stores SHA-256 of the token, never plaintext. `pre_reg_token` is cleared after `Register()`. `status` is `pending``active` on register, `offline` when `last_seen` passes the threshold (swept every 2 min).
- `servers.inventory` holds the latest metrics snapshot with separate `metrics_at` / `static_at` timestamps.
- `keys.private_key_enc` and `passphrase_enc` are AES-256-GCM; the JSON form exposes only `has_private_key` / `has_passphrase`.
- `assignments.revoked_at: null` means active. Revocation is soft, preserving audit history.
- `workflow_runs.steps_snapshot` freezes the resolved steps so editing the library never rewrites history.
- `console_sessions.token_consumed_at` is set atomically to enforce one-time use.
### `assignments`
### Migrations
```json
{
"_id": "ObjectId",
"key_id": "uuid",
"server_id": "uuid",
"assigned_at": "ISODate",
"revoked_at": "ISODate | null"
}
```
`services.RunMigrations()` runs at boot, recording markers in `migrations`:
- `revoked_at: null` = key is active on that server
- Revocation is soft — set `revoked_at`, agent picks it up on next poll
- `0001_default_org_backfill`
- `0002_settings_org_backfill` (must run before 0003 — 0003 can create a `default` org, which pushes 0002 into its ambiguous multi-org branch)
- `0003_missed_org_scopes`
Index builders (`EnsureAuthIndexes`, `EnsureSettingsIndexes`) are fatal on failure; `EnsureSecretIndexes` and `EnsureWorkflowIndexes` only warn.
---
## Agent Lifecycle
### Config file — `/etc/keymanager/config.yaml`
### Config file
Linux `/etc/vantage/config.yaml`, Windows `%ProgramData%\vantage\config.yaml`. Directory `0700`, file `0600`.
```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()
@@ -183,169 +289,171 @@ poll_interval: 30s
tls: true
```
Config file permissions: `0600`. Config directory: `0700`.
### Startup flow
### Startup
```
1. Load config
2. If pre_reg_token present:
→ call Register(server_id, pre_reg_token, hostname, ip, os_info)
→ save returned agent_token to config
→ delete pre_reg_token from config
3. Enter poll loop
2. If pre_reg_token present → Register() → save agent_token, clear pre_reg_token, reconnect
3. Start goroutines: command stream · update check (hourly) · inventory · monitors
4. Enter SyncKeys poll loop (default 30s)
```
### Poll loop (every 30s)
### Poll loop
```
1. Call SyncKeys(server_id, agent_token)
2. Receive []public_keys
3. Compute fingerprints of current /root/.ssh/authorized_keys
4. If state unchanged → skip write
5. If changed:
→ write to /root/.ssh/authorized_keys.tmp
→ os.Rename() to /root/.ssh/authorized_keys (atomic)
→ chmod 0600
1. SyncKeys(server_id, agent_token, agent_version)
2. Non-Linux hosts stop here — Windows agents register and heartbeat only
3. Diff desired keys against /root/.ssh/authorized_keys; unchanged → no write
4. Changed → write .tmp, os.Rename() over the real file, chmod 0600
```
### Key generation (on demand)
### Install
- Triggered by a flag or API call from the server
- Runs `ssh-keygen` via `exec.Command`
- Uploads public key via `UploadGeneratedKey()`
- Private key stays local on the machine
### Systemd unit — `/etc/systemd/system/keymanager-agent.service`
```ini
[Unit]
Description=KeyManager Agent
After=network.target
[Service]
ExecStart=/usr/local/bin/keymanager-agent
Restart=always
RestartSec=10
User=root
[Install]
WantedBy=multi-user.target
```
Linux: systemd unit at `/etc/systemd/system/vantage-agent.service`, `Restart=always`, runs as root.
Windows: MSI built by CI (WiX), or `installer/setup.ps1` registering the agent as a service via NSSM.
---
## Server Registration Flow
1. Click **Add Server** in the UI
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:
1. **Add Server** in the UI calls `POST /api/servers/new`, which generates a `server_id` and a pre-registration token (TTL 1 hour, single-use).
2. The UI shows a one-liner:
```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`
- Installs and starts systemd unit
5. On first `SyncKeys` call, server marks status as `active`
Windows gets the `/install.ps1` equivalent.
3. The script detects arch, downloads the agent from the Gitea release, verifies the SHA-256 checksum, writes the config, installs and starts the service.
4. The server flips to `active` on first sync.
The backend serves `/install` dynamically, injecting the latest agent version by querying the Gitea API for the most recent `agent/v*` release tag.
`/install` is served dynamically, injecting the latest agent version from the Gitea API.
---
## Environment Variables (server)
| Name | Required | Notes |
| -------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GRPC_HOST` | **yes** | `host:port` agents dial. Boot fails without it — there is no safe default; falling back to the web host would hand agents a port that does not speak gRPC. |
| `MONGO_URI` | no | default `mongodb://localhost:27017` |
| `MONGO_DB` | no | default `vantage` |
| `REDIS_ADDR` | no | default `localhost:6379` |
| `KEY_ENCRYPTION_KEY` | yes in practice | 64-char hex (32 bytes) for AES-256-GCM. Required for private keys, secrets, OIDC secrets, RDP credentials. |
| `GITEA_HOST` | yes | used to build install scripts and agent download URLs |
| `GUACD_ADDR` | no | default `guacd:4822` |
| `APP_ROOT_LABEL` | no | default `vantage`; wrong value disables the host/session org guard |
| `VANTAGE_WORKFLOW_LOG_DIR` | no | where run logs are written |
**sitesvc** (`deploy/docker-compose.site.yml` only):
| Name | Required | Notes |
| --------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `MONGO_URI` | yes | **must point at the control plane's database**, or the app will not see organisations created here. The database name is read from the URI path (`mongodb://user:pass@host:27017/vantage?authSource=vantage`); a URI without one is refused at boot rather than defaulted. Note this differs from the server, which takes `MONGO_DB` separately. |
| `PUBLIC_URL` | yes | sitesvc's own public base URL; verification links are built from it |
| `APP_LOGIN_URL` | no | template for the org sign-in URL a verified owner is redirected to. `{slug}` is replaced with the new org's slug (each org has its own subdomain), e.g. `https://{slug}.vantage.hostxtra.co.uk/login`. A value without `{slug}` is used verbatim; empty means a plain confirmation page. |
| `SMTP_HOST` / `SMTP_FROM` | yes | without them both forms refuse (503) rather than silently dropping |
| `SMTP_TO` | no | default `support@hostxtra.co.uk`; contact enquiries only |
| `SMTP_PORT` | no | default `587`; `465` uses implicit TLS |
| `SMTP_USERNAME` / `SMTP_PASSWORD` | no | auth skipped when username is empty |
| `SITE_ORIGIN` | yes in practice | comma-separated allowed origins; unset refuses every cross-origin browser request |
| `TRUST_PROXY` | no | only `true` behind a proxy that overwrites `X-Forwarded-For`, or clients spoof past the rate limiter |
`deploy/docker-compose.yml` runs four services: `redis`, `guacd`, `server` (8080 + 9090), `web` (3000). MongoDB is external. `deploy/docker-compose.site.yml` adds the public marketing site on `3001` and is only used on vantage.hostxtra.co.uk.
---
## 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`)
- 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
- Pre-registration tokens are short-lived (1 hour) and single-use
- Agent runs as `root` (required for `/root/.ssh/authorized_keys` writes)
- gRPC over TLS; agents connect outbound only, no inbound firewall holes on managed servers.
- Per-server agent token stored as SHA-256 on the server, plaintext only in the agent's `0600` config.
- Pre-registration tokens are short-lived (1 hour) and single-use.
- AES-256-GCM at rest for private keys, key passphrases, vault secrets, OIDC client secrets, RDP/VNC credentials.
- Console session tokens are one-time; RDP credentials are consumed on tunnel open.
- ESO read token stored as a SHA-256 hash and rotatable.
- Unique indexes on user email, org slug, settings org, and ESO token hash are load-bearing for tenant isolation.
- `authorized_keys` written `0600`, owned by root. The agent runs as root because it must.
- Every mutating API path writes an audit event.
---
## Frontend Routes
## Frontend
| Route | Purpose |
| --------------- | -------------------------------------------------------------------- |
| `/servers` | List all servers, online/offline status badge, last seen timestamp |
| `/servers/new` | Displays the one-liner install script with copy button |
| `/servers/[id]` | Keys installed on this server, trigger key generation, remove server |
| `/keys` | All keys — label, fingerprint, source, assigned count |
| `/keys/[id]` | Assign key to servers, revoke per server |
Next.js 16 (App Router) + React 18, Tailwind 3, TanStack Query. Guacamole client bundled locally in `web/lib/guacamole-common.js`.
| Route | Purpose |
| --------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `/setup` | First-run bootstrap: create the first org and owner |
| `/login` | Local or OIDC sign-in |
| `/` | Fleet dashboard |
| `/servers`, `/servers/new`, `/servers/[id]` | Fleet list, install one-liner, server detail (keys, inventory, updates) |
| `/servers/[id]/console` | Browser SSH/RDP/VNC session |
| `/keys`, `/keys/[id]` | Key library; assign and revoke per server |
| `/workflows`, `/workflows/[id]`, `/workflows/[id]/runs[/runId]` | Compose, run, and follow live logs |
| `/steps` | Reusable step library |
| `/monitors`, `/monitors/new`, `/monitors/[id][/edit]` | Checks, uptime, incidents |
| `/secrets`, `/secrets/[group]` | Vault |
| `/audit` | Audit log |
| `/settings`, `/settings/org`, `/settings/notifications` | Alerts, members, OIDC, channels |
---
## CI/CD — Gitea Actions
### Agent release — `.gitea/workflows/agent-release.yml`
### `agent-release.yml` — triggered by `agent/v*` tags
Triggered by a `agent/v*` tag. Cross-compiles for `linux/amd64` and `linux/arm64`, creates a Gitea release with binaries and checksums.
```yaml
on:
push:
tags:
- "agent/v*"
```
Build command:
Builds `linux/amd64`, `linux/arm64`, `windows/amd64`, writes `checksums.txt`, creates a Gitea release. A second `msi` job on `windows-2022` packages the WiX installer.
```bash
GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o dist/keymanager-agent-linux-amd64 ./cmd
-o dist/vantage-agent-linux-amd64 ./cmd
```
Release assets:
### `server-deploy.yml` — triggered on every push to `main`
- `keymanager-agent-linux-amd64`
- `keymanager-agent-linux-arm64`
- `checksums.txt`
Builds and pushes four images to the Gitea container registry: `server`, `web`, `site` and `sitesvc`.
### Server deploy — `.gitea/workflows/server-deploy.yml`
Triggered on pushes to `main` touching `server/**`, `web/**`, or `proto/**`. Builds and pushes Docker images to the Gitea container registry, then deploys via SSH:
Note that despite the name, **this workflow does not deploy**it only builds and pushes. There is no SSH step and no path filter; every push to `main` rebuilds all three images. Rolling them out is a separate manual step on the host:
```bash
cd /opt/keymanager && docker compose pull && docker compose up -d --remove-orphans
cd /opt/vantage && docker compose -f docker-compose.yml -f docker-compose.site.yml pull && \
docker compose -f docker-compose.yml -f docker-compose.site.yml up -d --remove-orphans
```
### Tagging convention
### Tagging
```bash
# Release a new agent version
git tag agent/v1.0.0 && git push origin agent/v1.0.0
# Server + web deploy automatically on push to main
git push origin main
git tag agent/v1.0.0 && git push origin agent/v1.0.0 # agent release
git push origin main # server + web deploy
```
### Required Gitea secrets / variables
### Secrets / variables
| Name | Type | Value |
| ------------------- | -------- | ------------------------------------------ |
| `RELEASE_TOKEN` | Secret | Gitea API token with `write:release` scope |
| `REGISTRY_USER` | Secret | Gitea username |
| `REGISTRY_PASSWORD` | Secret | Gitea token with `write:packages` scope |
| `DEPLOY_HOST` | Secret | IP/hostname of the server VM |
| `DEPLOY_USER` | Secret | SSH user for deploy |
| `DEPLOY_SSH_KEY` | Secret | Private key for deploy SSH |
| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` |
| Name | Type | Value |
| -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RELEASE_TOKEN` | Secret | Gitea API token, `write:release` |
| `REGISTRY_USER` | Secret | Gitea username |
| `REGISTRY_PASSWORD` | Secret | Gitea token, `write:packages` |
| `GITEA_HOST` | Variable | `gitea.hostxtra.co.uk` |
| `DOCKER_HOST` | Variable | registry host used for image tags |
| `API_URL` | Variable | baked into the `web` image at build time |
| `SITE_API_URL` | Variable | **browser-reachable** sitesvc URL, baked into the `site` image. Required — if empty, both forms report "not connected" and submit nowhere. Must also be in sitesvc's `SITE_ORIGIN`. |
| `SITE_CONTACT_EMAIL` | Variable | optional; address shown when a form is misconfigured |
---
## Design Decisions
- **gRPC over REST for agent communication** — strong typing, easy versioning, bi-directional streaming available later if push-based updates are needed
- **Poll-only, no streaming** — 30s interval is sufficient for a homelab; simplifies agent implementation
- **Outbound-only agent connections** — no inbound firewall holes required on managed servers
- **Atomic `authorized_keys` rewrite** — write to `.tmp` then `os.Rename()` prevents partial writes
- **Fingerprint diffing before write** — avoids unnecessary disk writes on unchanged state
- **Soft revocation** — `revoked_at` timestamp rather than hard deletes; preserves audit history
- **root only** — manages `/root/.ssh/authorized_keys` only; no per-user key management
- **Gitea releases for agent binaries** — slots into existing act_runner CI pipeline; install script queries Gitea API for latest version at serve time
- **gRPC for agent traffic** — strong typing and cheap versioning; polling for state, one bidirectional stream for commands.
- **Outbound-only agents** — no inbound ports on managed servers, works behind NAT.
- **Poll for keys, push for commands** — a 30s key poll is fine, but running a workflow step should not wait up to 30s.
- **Atomic `authorized_keys` rewrite** — temp file plus `os.Rename()`; a machine that dies mid-write keeps the old file.
- **Fingerprint diffing before write** — no disk churn on unchanged state.
- **Soft revocation** — `revoked_at` rather than deletes; preserves audit history.
- **Run snapshots** — workflow runs freeze their resolved steps so editing a step never rewrites past runs.
- **Monitors run in two places** — server-side for external endpoints, agent-side for anything only reachable from inside the target network.
- **Redis for sessions only** — all durable state stays in MongoDB; losing Redis logs everyone out and nothing else.
- **guacd for console** — protocol handling is Guacamole's problem, not ours; we proxy the WebSocket and manage credentials.
- **`org_id` on every document** — isolation enforced at the query layer, not by separate databases.
- **root only** — manages `/root/.ssh/authorized_keys`; no per-user key management.
- **Windows agents are second-class by design** — register, heartbeat, run steps, report inventory; no `authorized_keys` management.
+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
+27
View File
@@ -0,0 +1,27 @@
services:
site:
image: gitea.hostxtra.co.uk/mrhid6/vantage/site:latest
restart: unless-stopped
ports:
- 3001:3000
depends_on:
- sitesvc
sitesvc:
image: gitea.hostxtra.co.uk/mrhid6/vantage/sitesvc:latest
restart: unless-stopped
ports:
- 8082:8082
environment:
PORT: "8082"
MONGO_URI: ${MONGO_URI:-}
PUBLIC_URL: ${SITE_PUBLIC_URL:-}
APP_LOGIN_URL: ${SITE_APP_LOGIN_URL:-}
SITE_ORIGIN: ${SITE_ORIGIN:-}
TRUST_PROXY: ${SITE_TRUST_PROXY:-false}
SMTP_HOST: ${SITE_SMTP_HOST:-}
SMTP_PORT: ${SITE_SMTP_PORT:-587}
SMTP_USERNAME: ${SITE_SMTP_USERNAME:-}
SMTP_PASSWORD: ${SITE_SMTP_PASSWORD:-}
SMTP_FROM: ${SITE_SMTP_FROM:-}
SMTP_TO: ${SITE_SMTP_TO:-support@hostxtra.co.uk}
+24 -38
View File
@@ -1,65 +1,51 @@
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}
GRPC_HOST: ${GRPC_HOST}
GRPC_PORT: "9090"
HTTP_PORT: "8080"
OIDC_ISSUER: ${OIDC_ISSUER:-}
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: {}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,410 @@
# Spec 3 — Admin Backend
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`)
Ships: with spec 4 (the site it serves). Blocks specs 4 and 5.
## Context
A fourth Go service, `admin/`, owning customers, instances, licences and
subscriptions. It is the only service that holds the signing key.
Its data model separates two things the control plane deliberately does not know
about:
- **Account** — a paying customer. Holds a Paddle customer, a billing email, and
one or more instances.
- **Instance** — one deployment. Cloud instances mirror a control-plane
`Instance` row; self-hosted instances exist only here, because the customer's
database is theirs and we cannot see it.
## Goals
1. Issue, store and re-issue licences, with full history.
2. Inject licences into cloud instances.
3. Serve both staff and customers, with the right things hidden from each.
4. Never be a runtime dependency of a Vantage instance. If admin is down,
every instance keeps working; only purchasing and renewals stop.
## Non-goals
- The UI. Spec 4.
- Paddle. Spec 5. This spec defines the `subscriptions` table and the issuance
functions that spec 5's webhooks call, and nothing more.
- Rebuilding billing management. Card changes, invoices and cancellation go to
Paddle's own customer portal.
## Design
### Module
```
admin/
├── go.mod # replace => ../shared
├── cmd/main.go
└── internal/
├── api/ # gin handlers
├── auth/ # staff, cloud-customer and local-customer sessions
├── db/ # two connections: admin DB and control-plane DB
├── models/ # admin-owned documents
├── licensing/ # issuance, renewal, relink
├── inject/ # control-plane writes
├── mail/ # licence delivery
└── paddle/ # spec 5 lands here
```
Port `8083`. In `deploy/docker-compose.site.yml` only — like sitesvc, admin is
**excluded from the self-hosted deployment**. A self-hosted customer runs
instances, not the licensing authority.
### Two database connections
The service holds two:
- `ADMIN_MONGO_URI` — its own database, `vantage_admin`. Sole owner.
- `CONTROL_MONGO_URI` — the control plane's database, used to write licence
fields onto cloud instance documents and to authenticate cloud customers.
The control-plane connection uses the `Instance` and `User` structs from
`shared` (spec 0a). This is what makes direct writes safe: there is no admin-side
copy of the document shape to drift, which is the coupling hazard sitesvc used to
carry.
Admin's control-plane access is **narrow by construction**: it reads
`instances` and `users`, and it writes exactly three fields on `instances`. The
Mongo credential it is given should be scoped to that where the deployment allows
it. It must never write to any other collection.
### Data model
```go
type Account struct {
ID bson.ObjectID
AccountID string // uuid
Name string
BillingEmail string
PaddleCustomerID string // empty until first checkout
Status string // active | suspended
CreatedAt time.Time
}
type Instance struct {
ID bson.ObjectID
InstanceID string // for cloud: equals the control-plane instance_id
// for self-hosted: the UUID the customer pasted
AccountID string
Name string
Slug string // cloud only; the subdomain label
Deployment string // cloud | self_hosted
Tier string
Status string // awaiting_link | active | lapsed | cancelled
CurrentLicense string // licence ID
RelinkCount int // reset each term
CreatedAt time.Time
}
type License struct {
ID bson.ObjectID
LicenseID string
InstanceID string
AccountID string
Tier string
Deployment string
Limits license.Limits // snapshot
Features []string // snapshot
IssuedAt time.Time
ExpiresAt time.Time
Blob string
SupersededBy string // licence ID, when replaced
IssuedBy string // staff user, "system", or "paddle:<event id>"
Reason string // new | renewal | tier_change | relink | manual
}
type Subscription struct {
ID bson.ObjectID
SubscriptionID string
AccountID string
InstanceID string
PaddleSubscriptionID string
PaddlePriceID string
Tier string
Term string // monthly | annual
Status string // active | past_due | cancelled | awaiting_link
CurrentPeriodEnd time.Time
}
type Plan struct {
Tier string
Name string
Deployment string
Limits license.Limits
Features []string
PaddleProductID string
PaddlePriceIDs map[string]string // "monthly" | "annual"
Active bool
}
```
Collections: `accounts`, `admin_instances`, `licenses`, `subscriptions`,
`plans`, `staff_users`, `customer_users`, `admin_audit`.
Unique indexes: `accounts.account_id`, `admin_instances.instance_id`,
`licenses.license_id`, `subscriptions.paddle_subscription_id`, `plans.tier`,
`staff_users.email`, `customer_users.email`.
`admin_instances.instance_id` unique is load-bearing: it is what stops the same
self-hosted UUID being linked to two accounts.
**Licences are append-only.** A renewal writes a new row and sets
`SupersededBy` on the old one. Nothing is ever edited or deleted. When a support
question arrives about why a customer's instance stopped working on a given
date, the answer is in the table.
`plans` holds tier contents so they change without a deploy, seeded from the
table in spec 1. Every issued licence snapshots the plan, so editing a plan never
changes an existing licence — the same rule as `workflow_runs.steps_snapshot`.
### Issuance
```go
func Issue(ctx, instanceID, tier, term, reason, issuedBy string) (*models.License, error)
```
1. Load the instance and its account.
2. Load the plan for `tier`; refuse if `plan.Deployment != instance.Deployment`.
**This is the check that makes Free cloud-only** — Free's plan is
`deployment: cloud`, so it can never be issued to a self-hosted instance.
3. Build the payload with the instance's UUID bound in, `ExpiresAt` from the term
plus a **3-day grace** so a renewal webhook arriving slightly late does not
create a gap.
4. Sign with `LICENSE_SIGNING_KEY`.
5. Insert the licence row; set `SupersededBy` on the previous one; update
`instance.CurrentLicense` and `instance.Tier`.
6. If cloud, inject. If self-hosted, email the blob and make it downloadable.
7. Write an `admin_audit` entry.
Steps 5 and 6 are not transactional. Order matters: **record first, deliver
second.** A licence recorded but not delivered is recoverable — the customer
downloads it. A licence delivered but not recorded is a support mystery.
### Free tier rule
One Free instance per account, enforced in `Issue`: refuse a second Free instance
for an account that already has one that is not `cancelled`. Additional
instances must be paid.
### Injection
```go
func InjectCloud(ctx, instanceID string, lic *models.License) error
```
Writes `license_blob`, `license_tier`, `license_expiry` onto the control-plane
`instances` document via a single `UpdateOne`. Idempotent, retryable, and safe to
re-run.
Retries three times with backoff; on final failure the licence stays recorded and
`instance.Status` is set to `active` regardless, with the failure logged and
surfaced as a staff alert. A **reconciliation job runs every 15 minutes**,
comparing each cloud instance's `CurrentLicense` against the blob actually stored
in the control plane, and re-injecting on mismatch. That job, not the webhook, is
what guarantees eventual consistency.
The control-plane instance caches licence state for 60 seconds (spec 2), so an
injection takes effect within a minute without a restart.
### Self-hosted linking
The flow, end to end:
```
Customer runs /setup on their own install → instance UUID generated and shown
Customer buys Self Hosted in the admin site → subscription created,
status awaiting_link
Customer pastes the UUID into the admin site → admin_instances row created,
status active
Admin issues the licence with that UUID bound in
Customer downloads the .lic file or copies the blob
Customer pastes it into /settings/license on their install
```
Validation on link: the UUID must parse as a UUID, must not already exist in
`admin_instances`, and must not collide with a cloud instance ID. A duplicate
returns "That instance ID is already linked to an account" without revealing
which — it is a small enumeration surface but there is no reason to leave it
open.
### Relink
A rebuilt server has a new UUID. `POST /api/instances/:id/relink` with the new
UUID:
- Allowed **3 times per term**, `RelinkCount` reset on renewal.
- Updates `admin_instances.instance_id`, issues a replacement licence for the
**remaining term** with `reason: relink`, supersedes the old one.
- The old licence is not revoked — it cannot be, offline verification has no
revocation. It simply no longer matches any UUID the customer controls, and its
binding stops it being useful on a different machine anyway.
- Beyond 3, the endpoint returns a message directing the customer to support, and
staff can relink without limit.
`RelinkCount` is the abuse signal, not the abuse prevention. Its real job is to
put a human in front of the fourth attempt.
### Authentication
Three identities, three paths, one session store (Redis, `admin_session`
cookie, 24h).
**Staff** — `staff_users`, local email plus bcrypt. Full access. Created by CLI
only; there is no staff signup.
**Cloud customers** — authenticate against the control plane's `users`
collection with the credentials they already use. Admin looks the user up
by email, checks bcrypt, resolves their control-plane instance, then resolves the
account that owns it.
Two consequences, stated plainly because they are real:
1. A cloud user's control-plane password now also unlocks billing. Any password
change or compromise has a wider blast radius than before.
2. Only users with control-plane role `owner` may sign in to the admin site.
`admin` and `member` are refused. Billing is an owner concern.
Mitigations: rate-limit to 5 attempts per email per 15 minutes and 20 per IP per
hour; log every attempt to `admin_audit`; return an identical error for unknown
email and wrong password.
**Self-hosted customers** — `customer_users`, local email plus bcrypt at cost 12,
created during purchase, scoped to one account. Email verification reuses the
pattern sitesvc already proved: 32 random bytes, only the SHA-256 hash stored,
24-hour expiry, TTL index.
A single email address could in principle be both a cloud user and a
self-hosted customer user. `customer_users` is checked first; if it matches, that
identity wins. Documented so the behaviour is chosen rather than emergent.
### API
Staff:
```
GET /api/staff/accounts list, search
POST /api/staff/accounts
GET /api/staff/accounts/:id
GET /api/staff/instances filter by account, deployment, status, expiry
POST /api/staff/instances/:id/issue manual issue or reissue
POST /api/staff/instances/:id/relink no limit
GET /api/staff/licenses full history, filterable
GET /api/staff/plans
PUT /api/staff/plans/:tier
GET /api/staff/audit
GET /api/staff/health/injection reconciliation status and failures
```
Customer:
```
GET /api/account own account and instances
POST /api/instances/link self-hosted UUID link
POST /api/instances/:id/relink rate-limited
GET /api/instances/:id/license current licence metadata
GET /api/instances/:id/license/download .lic file
GET /api/subscriptions status, next renewal
POST /api/billing/portal Paddle portal redirect (spec 5)
```
Every customer handler resolves the account from the session and scopes by it.
The scoping is enforced by a helper every handler calls, not by each handler
remembering — the same deny-by-default reasoning as spec 2's middleware.
### Configuration
| Variable | Required | Notes |
|---|---|---|
| `ADMIN_MONGO_URI` | yes | admin's own database; name read from the URI path, refused if absent |
| `CONTROL_MONGO_URI` | yes | control-plane database, for injection and cloud auth |
| `REDIS_ADDR` | yes | sessions |
| `LICENSE_SIGNING_KEY` | yes | ECDSA P-384 private key, base32 (lk PrivateKey.ToB32String). **Boot fails without it** — a licensing service that cannot sign is worse than one that is down, because it looks healthy |
| `PUBLIC_URL` | yes | for verification and licence links |
| `SMTP_*` | yes | licence delivery |
| `ADMIN_ORIGIN` | yes | CORS allow-list |
| `TRUST_PROXY` | no | only behind a proxy that overwrites `X-Forwarded-For` |
| Paddle variables | spec 5 | |
### Backfill
Licences issued by `lkctl` during the spec 12 period exist only as blobs.
A one-shot `admin backfill --from=blobs.json` parses each with
`license.Parse`, creates the account, instance and licence rows, and marks them
`reason: manual`. Run once when admin goes live.
## Testing
**Issuance:**
1. `Issue` produces a licence that `license.Verify` accepts for that instance.
2. Deployment mismatch (Free plan, self-hosted instance) is refused.
3. A second Free instance for the same account is refused; a third paid one is
allowed.
4. Renewal supersedes the previous licence and leaves it in the table.
5. The issued licence snapshots the plan; editing the plan afterwards does not
change the issued licence.
6. Grace period: `ExpiresAt` is term end plus 3 days.
**Injection:**
7. `InjectCloud` writes all three fields; the control plane then reports `valid`.
8. Injection is idempotent across two calls.
9. Injection failure leaves the licence recorded and flags the instance.
10. The reconciliation job detects a control-plane blob that does not match
`CurrentLicense` and re-injects.
**Linking and relink:**
11. Linking an unknown UUID succeeds; linking one already linked is refused.
12. Relink issues a licence for the *remaining* term, not a fresh full term.
13. The fourth relink in a term is refused for a customer and allowed for staff.
14. `RelinkCount` resets on renewal.
**Auth:**
15. Cloud owner signs in with control-plane credentials; `admin` and `member`
roles are refused.
16. Unknown email and wrong password return identical errors and timing is not a
meaningful oracle.
17. Rate limits trigger at the documented thresholds.
18. Self-hosted customer cannot sign in before verifying their email.
19. A customer requesting another account's instance gets `404`, not `403`
no existence disclosure.
**Scoping:**
20. Every customer endpoint, called with a session for account A against a
resource of account B, returns `404`. Written as a table-driven test over the
route list so a new endpoint that forgets to scope fails the build.
## Verification before merge
1. Full suite green, including the scoping table test (test 20).
2. End to end, cloud: create account → create instance → issue Professional →
confirm the control-plane instance reports `valid` within 60 seconds with no
restart.
3. End to end, self-hosted: run `/setup` on a scratch install, copy the UUID,
link it, issue, download, paste, confirm `valid`.
4. Confirm admin's control-plane credential cannot write to `servers`, `keys` or
any collection other than `instances`.
5. Kill the admin service and confirm every Vantage instance keeps working
entirely normally.
## Risks
| Risk | Mitigation |
|---|---|
| Admin becomes a runtime dependency | Verification step 5; instances verify offline and never call admin |
| Signing key exposure | Single service, single variable, never in an image; rotation path from spec 1 |
| Cloud password now unlocks billing | Owner-only, rate-limited, audited, and stated in the release notes |
| Injection silently fails | Reconciliation every 15 minutes plus a staff health endpoint |
| Admin writes outside its remit in the control plane | Narrow code path; scoped Mongo credential; reviewed on every change |
| Self-hosted UUID squatted by another account | Unique index plus a non-disclosing error |
@@ -0,0 +1,203 @@
# Spec 4 — Admin Site
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 3 (`admin-backend`)
Ships: with spec 3. Can be developed in parallel with spec 5 once spec 3's API
is stable.
## Context
A fifth Next.js app, `adminsite/`, serving two audiences from one codebase:
- **Staff** — internal operators. Accounts, instances, licence history, plan
editing, injection health, audit.
- **Customers** — their own account, instances, licences and subscription state.
They share auth plumbing and a component library but almost no screens. The
split is by route group, so a customer route can never accidentally render a
staff view.
## Goals
1. A customer can buy, link a self-hosted instance, download a licence, and see
when it expires — without contacting anyone.
2. Staff can answer "why did this customer's instance stop working" in one screen.
3. Nothing about the marketing site or the control-plane UI changes.
## Non-goals
- Rebuilding billing management. Card details, invoices, payment methods and
cancellation all deep-link into Paddle's customer portal.
- Server management. This is not a second control plane; there is exactly one
link out to the instance and no data about servers, keys or workflows.
- Public signup for cloud. That stays on the marketing site (moving to admin's
backend in spec 5, but the *form* stays where customers already find it).
## Design
### App
Built exactly like `web/` and `site/`: Next.js 16 App Router, React 18, Tailwind
3, TanStack Query, `output: "standalone"`, `node:26-alpine`, listening on `3000`,
published as `3002`. In `docker-compose.site.yml` only.
`ADMIN_API_URL` is baked in at build time, as `API_URL` is for `web/`. It must be
**browser-reachable** and must appear in the backend's `ADMIN_ORIGIN`. Getting
this wrong is the single most common deployment failure in this repo's history —
`SITE_API_URL` has the same footgun documented in `CLAUDE.md` — so the app
renders an explicit "not connected" state rather than failing silently.
```
adminsite/
├── app/
│ ├── login/
│ ├── signup/ # self-hosted customer account creation
│ ├── verify/
│ ├── (customer)/
│ │ ├── page.tsx # account overview
│ │ ├── instances/[id]/
│ │ ├── instances/link/
│ │ ├── billing/
│ │ └── layout.tsx # customer nav, account guard
│ └── (staff)/staff/
│ ├── page.tsx # operations dashboard
│ ├── accounts/[id]/
│ ├── instances/[id]/
│ ├── licenses/
│ ├── plans/
│ └── layout.tsx # staff nav, staff guard
├── components/
└── lib/
```
Route-group layouts do the guarding. A customer session hitting `/staff/*` gets
redirected, not a 403 page — there is nothing to tell them about.
### Customer screens
**Overview** — the account, its instances as cards. Each card: name, cloud or
self-hosted, tier, licence state, expiry with days remaining, and a link either
to the instance's subdomain (cloud) or to its licence page (self-hosted).
Licence state is colour-coded and blunt: green valid, amber under 14 days, red
expired. An expired card says what still works — "servers and monitors are still
running; changes are disabled" — because that is the first thing a worried
customer wants to know.
**Instance detail** — tier, limits, features, subscription status, next renewal
date. For self-hosted: the linked UUID, a **Download licence** button, the blob
in a copy-to-clipboard box, and step-by-step paste instructions with the target
route named (`Settings → Licence` on their own install). A **Relink** action
showing the remaining allowance ("2 of 3 relinks remaining this term").
**Link an instance** — the self-hosted activation screen. Explains where to find
the UUID (shown on `/setup`, and permanently on `/settings/license`), takes the
paste, validates the format client-side, and on success issues the licence and
lands the customer directly on the download.
The whole flow — buy, link, download, paste — should be completable without
reading documentation. That is the bar for this screen.
**Billing** — subscription list with status and renewal date, plus a button to
Paddle's portal. Deliberately thin.
### Staff screens
**Dashboard** — the operational answers, not vanity metrics: licences expiring
in the next 14 days, subscriptions `past_due`, instances `awaiting_link` for more
than 48 hours, and **failed injections** from the reconciliation job. Each row
links straight to the thing that needs doing.
**Accounts** — searchable by name, email, Paddle customer ID and instance UUID.
Searching by UUID matters: a support email arrives containing a UUID and nothing
else.
**Account detail** — instances, subscriptions, customer users, audit trail.
**Instance detail** — everything about one instance, with the **full licence
history as a timeline**: issued, superseded, renewed, relinked, each with a
timestamp, reason and who did it. This is the screen that answers "why did this
stop working on the 14th". Actions: issue, reissue, relink without limit, and a
live view of the control-plane injection state for cloud instances.
**Licences** — global history, filterable by tier, deployment, expiry window and
issuance reason.
**Plans** — edit limits and features per tier. Two guard rails, because this
screen changes what every future customer gets:
- A confirmation step naming exactly what changes and stating that existing
licences are unaffected until reissued.
- The deployment field is not editable. Moving Free to `self_hosted` would break
the cloud-only rule that spec 1 leans on; changing it is a code review, not a
form field.
**Audit** — every mutating action, filterable.
### Design language
Visually distinct from `web/`. Staff regularly have both open, and a moment of
"which app am I in" before clicking Reissue is worth designing out. Different
accent colour and a persistent environment badge in the header (sandbox or
production, from a build-time flag) — clicking Issue against the wrong Paddle
environment should be hard.
Shared component patterns with `web/` where they exist; this is not a reason to
invent a second design system.
### Error and empty states
- Backend unreachable: a page-level "not connected" state naming
`ADMIN_API_URL`, matching the pattern the marketing site already uses.
- No instances yet: a customer-facing explanation of the two paths — buy cloud,
or buy self-hosted and link.
- `awaiting_link`: a prominent prompt on the overview, since a customer who has
paid and not linked is a customer who has paid for nothing yet.
- Licence download failure: show the blob inline as a fallback so the customer is
never blocked by a file download.
## Testing
Component and integration tests with mocked API responses. The repo has no
frontend test setup today; this is where one starts, scoped to the flows that
lose money or leak data when broken.
1. Customer session on `/staff/*` redirects; staff session reaches it.
2. Instance card renders correctly for each licence state, including expired,
and the expired copy names what still works.
3. Link flow: valid UUID succeeds and lands on download; malformed UUID is caught
client-side; already-linked UUID surfaces the backend's message.
4. Relink shows the remaining allowance and disables at zero with the support
message.
5. Licence download failure falls back to the inline blob.
6. Not-connected state renders when the API is unreachable.
7. Staff dashboard renders each alert category and links to the right resource.
8. Plan edit requires confirmation and shows the "existing licences unaffected"
wording.
9. Instance search by UUID returns the instance.
10. Licence history timeline renders every reason type in order.
## Verification before merge
1. Test suite green.
2. Full manual pass, self-hosted purchase to working licence, using only the UI
and no documentation — timed, and if it takes more than five minutes the flow
needs work.
3. Full manual pass, cloud: buy, confirm the licence appears in the control plane
within a minute, confirm the instance's own settings page agrees.
4. Staff pass: find an account by instance UUID, read its licence history,
reissue, confirm the control plane picks it up.
5. Responsive check at mobile width — a customer hit by an expiry email will open
this on a phone.
6. `docker build` from the repo root succeeds and the image runs with
`ADMIN_API_URL` baked in.
## Risks
| Risk | Mitigation |
|---|---|
| `ADMIN_API_URL` misconfigured at build | Explicit not-connected state; documented alongside the existing `SITE_API_URL` footgun |
| Staff action taken against the wrong environment | Persistent environment badge; confirmation on destructive actions |
| Customer confused by the self-hosted flow | Step-by-step link screen; five-minute bar in verification |
| Customer session reaching staff data | Route-group guards plus backend scoping (spec 3, test 20). Two layers, because one is not enough for this |
@@ -0,0 +1,353 @@
# Spec 2 — Instance Licensing and Enforcement
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`)
Ships: independently, with licenses issued by hand via `lkctl`. No admin site
needed.
## Context
Spec 1 defines what a license is. This spec makes the control plane hold one,
act on it, and let a self-hosted operator paste one in.
The guiding rule: **an expired license must never break a running fleet.** Agents
keep their keys, monitors keep watching, alerts keep firing. What stops is
growth and change. A customer whose card fails should be inconvenienced, not
paged at 3am because their monitoring went dark when Vantage decided to sulk.
## Goals
1. A license lives on the instance document and is verified on read.
2. Enforcement is deny-by-default: a new mutating route is gated because of where
it is mounted, not because someone remembered.
3. Degraded mode is obvious in the UI and reversible by pasting a valid license.
4. Self-hosted operators get an instance UUID they can hand to the admin site.
## Non-goals
- Issuing licenses. `lkctl` (spec 1) or the admin backend (spec 3).
- Any outbound network call. Verification is offline, permanently.
- Per-user or per-role licensing. The unit is the instance.
## Design
### Instance identity
Every install already has an `Instance` document with an `InstanceID` UUID. For
cloud instances this is created by signup; for self-hosted it is created by
`/setup`.
Change to `/setup`: after bootstrapping the first instance and its owner, the
setup page **displays the instance UUID** with a copy button and the text that
it is needed to activate a license. It is also shown permanently on
`/settings/license`.
No new identifier is invented. The instance UUID is the licensing identity.
### Storage
`shared/models.Instance` gains:
```go
LicenseBlob string `bson:"license_blob,omitempty" json:"-"`
LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"`
LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"`
```
The blob is authoritative. `LicenseTier` and `LicenseExpiry` are a denormalised
cache for listing and for the admin site's queries, rewritten from the verified
payload every time a blob is accepted. Nothing reads them for enforcement.
`LicenseBlob` is `json:"-"`. It is not a secret in the confidentiality sense —
it is signed public data — but there is no reason to spray it through API
responses.
### Runtime state
```go
type State struct {
Status license.State // valid | expired | invalid
Reason string
Tier string
ExpiresAt *time.Time
Limits license.Limits
Features map[string]bool
}
```
Resolved by `services.LicenseState(instanceID) State`, cached for 60 seconds
alongside the existing instance cache and invalidated immediately when a blob is
stored.
Three inputs, in precedence order:
1. `Instance.LicenseBlob`.
2. `VANTAGE_LICENSE` environment variable, used **only when the instance has no
stored blob**. This lets an automated self-hosted deployment ship a license
without a human pasting one. A blob stored through the UI always wins
afterwards, so an operator is never locked out by a stale environment value.
3. Neither → `Status: invalid`, `Reason: no_license`.
The verifier is called with `InstanceID` from the instance document and
`Deployment` from `VANTAGE_DEPLOYMENT` (`cloud` on our infrastructure,
`self_hosted` everywhere else, defaulting to `self_hosted`). The default matters:
an operator who removes the variable gets the stricter mode, not the looser one.
`invalid` and `expired` degrade identically. They differ only in the message.
### Enforcement
Three layers, deliberately separate because they answer different questions.
**Layer 1 — mutation gate.** A gin middleware `RequireActiveLicense` mounted on
the `/api` group, applying to every request whose method is not `GET` or `HEAD`.
```go
api := r.Group("/api", auth.RequireSession(), services.RequireActiveLicense())
```
Non-`valid``403 {"error":"license_required","state":"expired","reason":"..."}`.
Mounting at the group means **a route added tomorrow is gated by default**. That
is the whole point of putting it here rather than on individual handlers.
Explicit exemptions, allow-listed by path because they must work in degraded
mode:
| Route | Why |
|---|---|
| `POST /api/license` | Pasting a valid license is how you recover |
| `POST /auth/*` | Login and logout are outside `/api` already; listed for clarity |
| `DELETE` on any resource | Deleting is how you get back under a limit |
| `POST /api/servers/:id/apply-updates` | Security patching must never be paywalled |
The `DELETE` exemption deserves emphasis: a customer downgraded to Free with 10
servers must be able to remove 7 of them. Blocking deletes would trap them.
**Layer 2 — feature gate.** `RequireFeature(name)` on the route groups that need
it:
- `console``POST /api/console/connect`, `GET /api/console/tunnel`
- `oidc``GET,PUT /api/instance/oidc`
Missing feature → `403 {"error":"feature_unavailable","feature":"console"}`.
OIDC needs care: `/auth/oidc/start` and `/auth/oidc/callback` are unauthenticated
and outside `/api`. They check the feature directly and, if unavailable, redirect
to `/login?error=oidc_unavailable` rather than returning JSON. **Existing OIDC
sessions are not terminated** — losing the feature stops new SSO logins, it does
not evict people mid-session.
**Layer 3 — limits.** Enforced in the service layer, because a limit needs a
count that middleware does not have:
| Limit | Checked in |
|---|---|
| `max_servers` | `services.CreateServer` / `POST /api/servers/new` |
| `max_secret_groups` | `services.CreateSecretGroup` |
| `max_channels` | `services.CreateChannel` |
`-1` means unlimited. Over limit → `403 {"error":"limit_exceeded","limit":"max_servers","current":3,"max":3}`.
Counts are of live rows: revoked assignments and deleted servers do not count.
**Over-limit instances are never truncated.** A Professional instance with 20
servers that lapses to Free keeps all 20 running; it simply cannot add a 21st.
Deleting resources is always permitted. Silently disabling a customer's servers
because their card expired is not a behaviour this system will have.
### Background work in degraded mode
This is where "read-only" needs to be specific, because these paths do not go
through gin at all.
| Subsystem | Degraded behaviour |
|---|---|
| **Monitor scheduler** | **Keeps running.** Checks execute, incidents open, notifications fire. |
| Monitor create/edit/delete | Blocked by layer 1 (delete exempted). |
| Workflow runner | New runs blocked by layer 1. **In-flight runs finish** rather than being killed mid-step — a half-run workflow is worse than a completed one. |
| Agent `SyncKeys` | Returns the existing desired key set unchanged. Nothing is torn off disk. New assignments cannot be created, so nothing changes anyway. |
| Agent registration | A **new** agent registering against an over-limit instance is refused with a clear message; existing agents re-register freely. |
| Inventory, heartbeat, update reporting | Unaffected. |
| `ApplyUpdatesCmd` | Allowed. Security patching is not gated. |
| ESO secrets read (`GET /api/secrets/:group/values`) | **Allowed.** It is a `GET`, and breaking a Kubernetes cluster's secret sync over a billing state is disproportionate. |
| Log retention sweep, offline sweep | Unaffected. |
Keeping monitors alive is a deliberate reversal of a stricter earlier draft. It
is the single most important line in this spec: **billing state must not take
away a customer's ability to know their infrastructure is on fire.**
### API
```
GET /api/license any authenticated user
POST /api/license owner only
```
`GET` returns:
```json
{
"instance_id": "…",
"state": "valid",
"reason": "",
"tier": "professional",
"expires_at": "2027-07-24T00:00:00Z",
"days_remaining": 365,
"limits": { "max_servers": -1, "max_secret_groups": -1, "max_channels": -1 },
"features": { "console": true, "oidc": true },
"usage": { "servers": 12, "secret_groups": 4, "channels": 2 },
"source": "stored"
}
```
`usage` is included so the UI can render "12 of 3 servers" honestly when an
instance is over its limit, rather than pretending.
`POST` takes `{"blob": "..."}`, verifies with the instance's own ID and
deployment mode, and on success stores the blob, refreshes the cache, and writes
an audit event. On failure it returns `400` with the specific reason:
| Reason | Message |
|---|---|
| `bad_signature` | This licence key is not valid. Check it was copied in full. |
| `deployment_mismatch` | This licence is for Vantage Cloud and cannot be used on a self-hosted install. |
| `instance_mismatch` | This licence was issued for a different instance. Your instance ID is `<uuid>`. |
| `expired` | This licence expired on `<date>`. |
An **expired** blob is still stored if it is otherwise valid, so the UI can show
what expired and when. An **invalid** blob is rejected and the previous one kept.
Rate-limited to 10 attempts per instance per hour. There is no oracle here worth
protecting, but an unbounded verify endpoint is an unbounded CPU endpoint.
### Frontend
`useLicense()` hook over `GET /api/license`, cached by TanStack Query and
invalidated after a successful paste.
- **Banner, persistent, top of every page** when `state != valid`:
- `expired` — "Your Vantage licence expired on `<date>`. Your servers and
monitors are still running, but changes are disabled until it is renewed."
with a link to the admin site.
- `invalid` / `no_license` — "This instance has no valid licence. Add one in
Settings → Licence."
- **Warning banner** in the final 14 days of a valid term, dismissible per
session.
- **Gated features render disabled with an upgrade tooltip, not hidden.** A
customer cannot buy what they cannot see, and a feature that vanishes reads as
a bug.
- **Limit indicators** on the servers, secrets and channels list pages: "3 of 3
servers used" with the create button disabled at the cap.
- `/settings/license`: current state, tier, expiry, limits with live usage, the
instance UUID with a copy button, and a textarea plus file upload for a new
blob. Owner-only; other roles see the state read-only.
### Grandfathering existing tenants
Migration `0005_grandfather_licenses`, cloud only, guarded on
`VANTAGE_DEPLOYMENT == "cloud"`:
For every instance with no `license_blob`, issue a Professional license expiring
**one year** from the migration date and store it.
The migration cannot sign — the server has no private key and, per spec 1, no
signing code. So the blobs are **generated ahead of time with `lkctl`** and
supplied to the migration through `VANTAGE_GRANDFATHER_BLOBS`, a JSON map of
instance ID to blob. The migration stores what it is given, verifies each blob
against its instance before storing, and logs any instance it had no blob for.
Clumsy, and correct. The alternative is putting a signing key in the control
plane, which is the thing this design most wants to avoid.
Self-hosted installs are not grandfathered. On upgrade they land in `no_license`
and read-only until an operator pastes a key — which is the intended behaviour
for a paid product, and is why the release notes must lead with it.
## Testing
**Unit, no database:**
1. `State` resolution precedence: stored blob wins over `VANTAGE_LICENSE`;
environment used when no blob; neither → `no_license`.
2. Feature map construction from the payload's `Features` slice.
3. Limit comparison with `-1`, with zero, and with a count exactly at the cap.
**Middleware, with a stub state:**
4. `GET` passes in every state.
5. `POST`/`PUT`/`DELETE` pass when `valid`, fail `403` when `expired` and when
`invalid` — except `DELETE`, which passes in all states.
6. `POST /api/license` passes when `expired` (the recovery path).
7. `POST /api/servers/:id/apply-updates` passes when `expired`.
8. `RequireFeature("console")` passes with the feature, `403`s without it.
9. **Coverage test:** enumerate every registered route and assert that every
non-`GET` route is either behind `RequireActiveLicense` or on the exemption
allow-list. This test is what stops layer 1 rotting as routes are added.
**Service layer, against MongoDB:**
10. `CreateServer` at the cap → `limit_exceeded`; one below → succeeds.
11. Over-limit instance can still `DELETE` a server, and can create again once
back under the cap.
12. Deleted and revoked rows do not count toward limits.
**Degraded background behaviour:**
13. Monitor scheduler executes checks for an instance with an expired license.
14. An incident opened during degraded mode still dispatches notifications.
15. `SyncKeys` for an expired instance returns the same key set as before expiry.
16. A new agent registering against an over-limit instance is refused; an
existing agent re-registers successfully.
17. A workflow run in flight when the license expires completes its remaining
steps.
**API:**
18. `POST /api/license` with a valid blob stores it and flips state to `valid`.
19. Each rejection reason returns its own message and leaves the stored blob
untouched.
20. An expired-but-well-formed blob is stored and reported as `expired`.
21. Non-owner `POST``403`.
**Migration:**
22. `0005` stores and verifies supplied blobs, skips instances that already have
one, logs instances with no blob supplied, and is a no-op when
`VANTAGE_DEPLOYMENT != "cloud"`.
## Verification before merge
1. Full test suite green, including the route-coverage test (test 9).
2. Manual pass on a scratch instance: issue a Professional license with `lkctl`,
paste it, confirm everything works. Issue one expiring in 60 seconds, wait,
confirm the banner appears, mutations `403`, **monitors keep firing**, and
pasting a fresh license restores normal operation without a restart.
3. Manual pass on the Free tier: confirm the 3-server cap, that console and OIDC
are visibly disabled with upgrade tooltips, and that a 4th server is refused
with a clear message.
4. Confirm a cloud-issued Free license is rejected on a `self_hosted` install
with `deployment_mismatch`.
5. Confirm a license issued for another instance is rejected with
`instance_mismatch` and the message shows the correct local UUID.
## Rollout
1. Generate grandfather blobs with `lkctl` for every existing cloud instance.
2. Deploy with `VANTAGE_GRANDFATHER_BLOBS` set; migration `0005` runs.
3. Verify every cloud instance reports `valid`, Professional, one year out.
4. Unset the variable on the next deploy — it is single-use.
5. Release notes for self-hosted must state plainly that upgrading requires a
licence key, and how to get one.
## Risks
| Risk | Mitigation |
|---|---|
| A mutating route added later without a gate | Route-coverage test (test 9) fails the build |
| Customer locked out and unable to recover | `POST /api/license` and all `DELETE`s exempt from the gate |
| Existing cloud tenants degrade on deploy | Migration 0005, verified before the traffic switch |
| Over-limit customer trapped | Deletes always allowed; existing resources never truncated |
| Clock wrong on a self-hosted host | `Verify` warns on a future `IssuedAt`; documented in the licence settings page |
| Monitoring lost on billing failure | Explicitly designed out — the scheduler ignores licence state |
@@ -0,0 +1,240 @@
# Spec 0b — Org to Instance Rename
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a (`shared-module`)
Ships: independently, before any licensing code
## Context
The licensing model separates two concepts that the codebase currently conflates
under one word:
- **Account** — a paying customer. Lives only in the admin control plane
(spec 3). The control plane never learns about it.
- **Instance** — one deployment of Vantage: its own subdomain, its own users,
its own servers, keys, workflows, monitors and secrets. One license attaches
to one instance.
Today's control-plane `Org` **is** an Instance. An Account may hold several,
some cloud and some self-hosted, and the self-hosted ones have no row in the
cloud database at all.
Keeping the name `Org` would leave the control plane using a word that means
something different in the admin site, in Paddle, and in every support
conversation. This spec renames it everywhere, including on disk.
This is the highest-risk change in the programme: `org_id` is the tenant
isolation key on every document in every collection. It is done alone, before
anything else, so that nothing else is in flight when it deploys.
## Goals
1. `Instance` is the only word for a tenant, in code, API, UI and database.
2. No document is lost and no tenant scoping is weakened.
3. The migration is reversible.
## Non-goals
- Any behaviour change. Same routes' semantics, same permissions, same data.
- Introducing Accounts. The control plane never gets them.
- Touching the agent. It talks gRPC and has no concept of a tenant.
## Design
### Naming map
| Today | After |
|---|---|
| collection `orgs` | `instances` |
| collection `org_oidc` | `instance_oidc` |
| field `org_id` (all collections) | `instance_id` |
| `models.Org` | `models.Instance` |
| `Org.OrgID` | `Instance.InstanceID` |
| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` |
| `services/orgs.go`, `GetOrg`, `CreateOrg`, `ListOrgIDs`, `CountOrgs`, `FirstOrg`, `AdoptOrg`, `GetOrgBySlug` | `services/instances.go`, `GetInstance`, `CreateInstance`, … |
| `services/org_oidc.go` | `services/instance_oidc.go` |
| `auth/orghost.go` | `auth/instancehost.go` |
| `/api/org/users`, `/api/org/oidc` | `/api/instance/users`, `/api/instance/oidc` |
| `shared/provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` |
| session field `org_id` | `instance_id` |
| `GET /auth/me` response `org_id` / `org` | `instance_id` / `instance` |
| UI copy "Organisation" | "Instance" |
Reserved slugs gain no new entries here, but note `admin` is already reserved,
which the admin site relies on later.
### Collections carrying `org_id`
All of: `servers`, `keys`, `assignments`, `users`, `org_oidc`, `settings`,
`secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `monitors`,
`incidents`, `monitor_rollups`, `notification_channels`, `console_sessions`,
`audit_logs`, plus `orgs` itself. `migrations` does not carry one.
`site_pending_signups` does not carry `org_id`, but its `org_name` field becomes
`instance_name` for consistency; it is sitesvc-private so this is free.
The migration must derive this list from a constant in code, not from a
hand-written list in a runbook, so that a collection added between design and
deploy is not silently missed:
```go
var scopedCollections = []string{ /* the list above */ }
```
A boot-time assertion (spec 2 onwards) checks that no collection outside this
list contains an `org_id` field. Cheap insurance against a future collection
being added without being renamed.
### Migration `0004_org_to_instance`
Recorded in `migrations` like the existing three. Runs after
`0003_missed_org_scopes`.
**The migration only renames. It never deletes and never drops.** A bad deploy
is recovered by running the inverse rename, not by restoring a backup.
Steps, in order:
1. **Guard.** If collection `instances` already exists and `orgs` does not, the
migration has already run against this database by an earlier binary; record
the marker and return. Idempotency matters because the marker write and the
data work are not in one transaction.
2. **Rename collections.** `orgs``instances`, `org_oidc``instance_oidc`,
via `adminCommand{renameCollection}`. Fails loudly if the target exists.
3. **Rename the field.** For each collection in `scopedCollections`:
`UpdateMany({org_id: {$exists: true}}, {$rename: {"org_id": "instance_id"}})`.
Record `matched` and `modified` per collection in the log.
4. **Verify.** For each collection, assert
`CountDocuments({org_id: {$exists: true}}) == 0` and
`CountDocuments({instance_id: {$exists: true}}) == totalCount`. Any mismatch
aborts before the marker is written, leaving the migration to retry.
5. **Indexes.** Drop and recreate indexes that name `org_id` in their key spec:
unique `settings.instance_id`, the ESO token-hash index, and any compound
scoping indexes. Unique `instances.slug` and `users.email` are unaffected by
the field rename but are re-declared idempotently.
6. **Write the marker.**
Steps 24 are not atomic across collections. Mongo multi-document transactions
would require a replica set, which is not guaranteed for self-hosted installs.
Instead the migration is written to be **safely re-runnable**: `$rename` on a
document that has already been renamed matches nothing, and the collection
rename is guarded in step 1.
Rollback, if ever needed, is the same code with the rename reversed, shipped as
a one-shot command rather than a migration — deliberately manual, because the
only reason to run it is a decision to revert the release.
### Version skew
`sitesvc` and `server` write the same documents. A skew where one writes
`org_id` and the other reads `instance_id` creates tenants that are invisible to
the application — the exact failure `CLAUDE.md` warns about.
After spec 0a both read the shape from `shared`, so the skew window is a
deployment-ordering problem rather than a code-drift problem:
- Both images are built from the same commit and deployed together.
- The migration runs from the `server` container at boot, as the existing three
do.
- `sitesvc` at boot asserts that collection `instances` exists and refuses to
start otherwise, with the message
`instances collection not found; deploy the control plane first`. Failing to
start is strictly better than provisioning into a collection nobody reads.
The self-hosted deployment runs no sitesvc, so it sees only the server change.
### API and frontend
REST route renames are **breaking**, but every consumer is first-party (`web/`)
and ships in the same release. No compatibility aliases — a permanent dual path
in the tenant-scoping layer is worse than a coordinated release.
`web/` changes: the API client's paths, the `useMe` shape, all UI copy from
"Organisation" to "Instance", and the settings route `/settings/org`
`/settings/instance`.
`site/` marketing copy changes where it says "organisation" about a tenant. Where
it means the customer, it becomes "account" — that word now has a specific
meaning and the marketing site is the first place a customer meets it.
## Testing
**No automated tests.** Decision taken 2026-07-24, consistent with spec 0a.
This is the change where that costs the most: it moves the tenant isolation key
across 17 collections, and a mistake orphans a customer's entire fleet rather
than breaking a build. The compensating controls are therefore not optional, and
the implementation plan makes each a mandatory step:
1. **Dry run against a restored copy** before the code is even committed —
migrate a `mongorestore`d duplicate of production and read the per-collection
rename counts.
2. **Idempotency by hand** — run the dry run twice; the second must complete
with no error and nothing left to rename.
3. **Interrupted-run recovery by hand** — rename `orgs` manually, then run the
migration; it must complete and leave every document carrying `instance_id`.
4. **Count comparison against a production snapshot** — record every
collection's document count before and after; any difference stops the
release.
5. **Per-tenant isolation comparison** — for three real tenants, count rows in
`servers`, `keys`, `workflows`, `monitors`, `secrets` and `audit_logs` by
`org_id` before and by `instance_id` after. Identical, or the release stops.
This is the check that proves tenant isolation survived.
6. **Stale-field sweep** — assert no collection anywhere still holds an
`org_id`.
7. **Rollback rehearsal** — migrate a third copy, run `rename-rollback`, confirm
the counts return to baseline and the pre-release binary boots against it.
Deploying without having done this is not permitted.
8. **Boot guard, both directions** — sitesvc must refuse an unmigrated database
and start normally against a migrated one.
`AssertNoScopedCollectionMissed` runs at every boot and is fatal. With no test
suite it is the standing protection against a future collection being added
without being added to `ScopedCollections`.
## Verification before merge
Run against a **restored production snapshot**, not a synthetic database:
1. Record `db.getCollectionNames()` and per-collection `countDocuments()` before.
2. Run the migration.
3. Assert every count is identical afterwards.
4. Assert `instances.countDocuments()` equals the old `orgs.countDocuments()`.
5. Pick three real tenants; run the same scoped query before (by `org_id`) and
after (by `instance_id`) and confirm identical result sets. This is the test
that proves tenant isolation survived.
6. Boot the server against the migrated snapshot; log in as a real user; confirm
servers, keys, workflows, monitors and secrets all list correctly.
7. Boot sitesvc against the migrated snapshot; complete a signup end to end.
8. Boot sitesvc against an **un**migrated snapshot; confirm it refuses to start
with the expected message.
## Rollout
1. Take a database backup. Not optional — this is the one change where the
inverse rename is the recovery path and the backup is the second.
2. Deploy `server`, `web`, `site` and `sitesvc` from one commit, together.
3. Server boots, migration runs, marker recorded.
4. Watch for the sitesvc guard message; if it appears, sitesvc started first and
will restart cleanly.
Expect a short window during the server restart where the API is unavailable.
Agents are unaffected: they reconnect, and no gRPC message carries a tenant ID.
## Risks
| Risk | Mitigation |
|---|---|
| Partial migration leaves mixed field names | Step 4 verification aborts before the marker; migration is re-runnable |
| A collection missed from the list | List is a code constant plus a completeness test plus a boot-time assertion |
| sitesvc deployed before server | Boot guard refuses to start |
| An index still keyed on `org_id` | Step 5 drops and recreates; verification includes an index listing diff |
| A hard-coded `org_id` string outside the model layer | `grep -rn '"org_id"' server/ sitesvc/ shared/` must return only the migration file after the change |
| Frontend missed a renamed route | Full manual pass over every route in the UI before release |
## Follow-on
With `Instance` established, spec 1 (`licensing-core`) can define a license
payload that binds to `instance_id` without inventing a word the codebase does
not use.
@@ -0,0 +1,294 @@
# Spec 1 — Licensing Core
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a (`shared-module`), spec 0b (`instance-rename`)
Ships: independently. Adds a package and a CLI; changes no running behaviour.
## Context
Licenses are **offline-verified signed blobs**. A Vantage server checks a
signature and an expiry date and asks nobody's permission. That choice buys
self-hosted installs that work in air-gapped networks and a control plane with no
licensing availability dependency.
It costs revocation. Once issued, a license is valid until it expires, whatever
Paddle later says. Every other decision in the programme follows from accepting
that: Self Hosted is annual-only so the unenforceable window is bounded, and
cancellation takes effect at term end rather than immediately (spec 5).
This spec defines the payload, the signing and verification, and a CLI to issue
licenses by hand. It deliberately lands before the admin site so that specs 1+2
together give working licensing with no new service to operate.
## Goals
1. One struct, in `shared`, read identically by the verifier and the issuer.
2. Verification that needs no network, no clock sync beyond a rough one, and no
configuration.
3. A hand-issuance path good enough to run production on until spec 3 lands.
## Non-goals
- Storing licenses. Spec 2 owns the instance document; spec 3 owns issuance
history.
- Deciding tier contents. Tiers are data; the values in this spec are the
initial seed, and spec 3's `plans` table becomes their home.
- Any phone-home, revocation list or online check. There is none, anywhere, by
design.
## Design
### Package
`shared/license/`, inside the module created by spec 0a:
```
shared/license/
├── license.go # License, Limits, feature constants
├── sign.go # Sign, build-tagged out of the server binary
├── verify.go # Verify, Parse
├── keys.go # trustedPublicKeys
└── license_test.go
```
Uses `github.com/hyperboloide/lk` (ECDSA P-384 with SHA-256, base32 encoding).
### Payload
```go
package license
type License struct {
ID string `json:"id"` // uuid, for support and audit
InstanceID string `json:"instance_id"` // the instance this license is bound to
AccountID string `json:"account_id"` // admin-side customer, informational
InstanceName string `json:"instance_name"` // display only
Tier string `json:"tier"` // "free" | "professional" | "self_hosted"
Deployment string `json:"deployment"` // "cloud" | "self_hosted"
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"`
Limits Limits `json:"limits"`
Features []string `json:"features"`
}
type Limits struct {
MaxServers int `json:"max_servers"` // -1 means unlimited
MaxSecretGroups int `json:"max_secret_groups"`
MaxChannels int `json:"max_channels"`
}
const (
FeatureConsole = "console" // browser SSH/RDP/VNC
FeatureOIDC = "oidc" // per-instance single sign-on
)
const (
TierFree = "free"
TierProfessional = "professional"
TierSelfHosted = "self_hosted"
DeploymentCloud = "cloud"
DeploymentSelfHosted = "self_hosted"
)
```
`InstanceID` is **always populated**. There is no unbound license: the
self-hosted purchase flow (spec 4) links the instance UUID before the license is
issued, so binding happens at signing time. This removes the claim endpoint, the
best-effort phone-home and the multi-claim reconciliation that an unbound design
would have needed.
**The server never branches on `Tier`.** It reads `Limits` and `Features` only.
`Tier` exists for display, support and analytics. Adding a tier, or changing what
a tier includes, must never require a server release.
### Tier seed values
Recorded here as the initial contents of spec 3's `plans` table. Snapshotted into
each license at issue, so changing the table never rewrites an issued license —
the same principle as `workflow_runs.steps_snapshot`.
| | Free | Professional | Self Hosted |
|---|---|---|---|
| `deployment` | `cloud` | `cloud` | `self_hosted` |
| `max_servers` | 3 | -1 | -1 |
| `max_secret_groups` | 1 | -1 | -1 |
| `max_channels` | 1 | -1 | -1 |
| `console` | no | yes | yes |
| `oidc` | no | yes | yes |
| billing term | monthly, £0 | monthly or annual | **annual only** |
Free is cloud-only. A self-hosted install can never hold a valid Free license
because Free is only ever signed with `deployment: "cloud"`, and verification
rejects a deployment mismatch. There is no server-side flag to edit.
### Signing
```go
//go:build !noSign
func Sign(l License, privateKeyHex string) (string, error)
```
Marshals to canonical JSON, signs with lk, returns the base32 blob.
`Sign` is excluded from the server binary with a build tag. The server has no
reason to hold signing code and there is no reason to ship it into a customer's
data centre.
The private key lives in `LICENSE_SIGNING_KEY` on the issuing side only — the
CLI now, the admin backend from spec 3. It is never in the repo, never in an
image, never in the control plane's environment.
### Verification
```go
type VerifyOpts struct {
InstanceID string // required: the verifier's own instance
Deployment string // required: "cloud" or "self_hosted"
Now time.Time // injectable for tests
}
type Result struct {
License License
State State // Valid, Expired, Invalid
Reason string
}
const (
StateValid State = "valid"
StateExpired State = "expired"
StateInvalid State = "invalid"
)
func Verify(blob string, opts VerifyOpts) Result
```
Checks, in order, stopping at the first failure:
1. Blob decodes and the signature verifies against one of `trustedPublicKeys`.
Failure → `Invalid`, reason `bad_signature`.
2. `l.Deployment == opts.Deployment`. Failure → `Invalid`, reason
`deployment_mismatch`. This is the check that makes Free cloud-only.
3. `l.InstanceID == opts.InstanceID`. Failure → `Invalid`, reason
`instance_mismatch`.
4. `opts.Now.Before(l.ExpiresAt)`. Failure → `Expired`.
5. Otherwise `Valid`.
**`Expired` and `Invalid` are distinct states and the caller treats them
differently in messaging** (spec 2), even though both degrade the instance the
same way. A customer whose card failed and a customer who pasted the wrong blob
need different words.
`Parse(blob) (License, error)` verifies the signature only, ignoring binding and
expiry. Used by the admin site to display a license and by support to inspect a
blob a customer has emailed in. Never used for enforcement.
Clock skew: no tolerance is applied. Terms are a month or a year; a server whose
clock is wrong by enough to matter has bigger problems, and a tolerance window is
a thing to get wrong. `Verify` logs at warn level if `IssuedAt` is in the future,
which is the signal that a clock is badly off.
### Key management
```go
// trustedPublicKeys is ordered. Index 0 is the current signing key.
// To rotate: prepend the new key, ship a server release, then reissue.
// Remove a retired key only after every license signed with it has expired.
var trustedPublicKeys = []string{
"<base32 ECDSA P-384 public key>",
}
```
A slice from day one even though it holds one entry, because retrofitting a
single-key verifier into a multi-key one during an incident is not a thing to
plan for.
Public keys are compiled in. They are not configurable, because a configurable
trust root is a licensing bypass: a self-hosted operator could point it at a
keypair they generated.
Key generation is a documented one-off:
```
go run ./shared/license/cmd/lkgen keypair
```
prints a private key (base32) for the vault and a public key (base32) to paste into
`keys.go`. The private key is stored in a password manager and in the admin
service's environment. **If it is lost, no new licenses can be issued for any
existing customer without a server release.** Back it up in two places.
### CLI issuer
`shared/license/cmd/lkctl`, built only for internal use:
```
lkctl keypair
lkctl issue --instance-id=<uuid> --instance-name="Acme" \
--tier=professional --deployment=cloud \
--term=1y [--account-id=<id>] [--out=acme.lic]
lkctl inspect <file-or-blob>
```
`issue` reads `LICENSE_SIGNING_KEY`, applies the tier seed values from a table
compiled into the CLI, and prints the blob. `--term` accepts `1m`, `1y` or an
explicit `--expires=RFC3339`.
This is the production issuance path until spec 3 ships. It is kept afterwards
for support and disaster recovery — if the admin service is down and a customer's
license expires, a blob can still be cut by hand.
Issued blobs from `lkctl` are not recorded anywhere. Spec 3 backfills its
`licenses` table from `inspect` output when it takes over.
## Testing
`shared/license` is pure and needs no database, so this suite is fast and
thorough. Written test-first.
1. Round trip: `Sign` then `Verify` returns `Valid` with an identical payload.
2. Tampering: flip one character of the blob → `Invalid`, `bad_signature`.
3. Tampering with intent: re-sign a payload with a *different* keypair →
`Invalid`. This is the test that proves an attacker cannot mint licenses.
4. Expiry: `ExpiresAt` one second in the past → `Expired`. One second in the
future → `Valid`.
5. Deployment mismatch: a Free (`cloud`) license verified with
`Deployment: "self_hosted"``Invalid`, `deployment_mismatch`.
6. Instance mismatch: correct signature, different `InstanceID``Invalid`,
`instance_mismatch`.
7. Check order: a blob that is both expired *and* instance-mismatched reports
`instance_mismatch`, not `Expired`. Order is part of the contract because the
reason drives the message.
8. Multi-key: a license signed with `trustedPublicKeys[1]` verifies. One signed
with a key not in the slice does not.
9. `Parse` returns the payload for an expired and for a mismatched license, and
errors for a bad signature.
10. Unicode and long instance names survive the round trip.
11. Golden blob: a fixture blob checked into the repo, signed with a **test-only**
keypair, must keep verifying. This catches an accidental change to the
canonical JSON encoding, which would silently invalidate every issued
license in the field.
Test 11 matters more than it looks. The encoding is part of the wire format.
## Verification before merge
1. `go test ./shared/license/...` passes, including the golden fixture.
2. `lkctl keypair``lkctl issue``lkctl inspect` round trips at the command
line.
3. `go build -tags noSign ./server/...` succeeds and
`go tool nm` on the resulting binary shows no `license.Sign` symbol.
4. The production keypair is generated, the private half stored in two places,
and the public half committed in `keys.go`.
## Risks
| Risk | Mitigation |
|---|---|
| Signing key lost | Documented two-location backup; generation is a one-off with an explicit checklist |
| Signing key leaked | Rotation path exists from day one: prepend key, release, reissue. Retire the old key once its licenses expire |
| Canonical encoding changes | Golden fixture test |
| Signing code shipped to customers | Build tag plus a symbol check in verification |
| No revocation | Accepted and documented. Bounded by term length; Self Hosted is annual-only |
@@ -0,0 +1,271 @@
# Spec 5 — Paddle Billing
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 3 (`admin-backend`)
Ships: after spec 3. Can be developed in parallel with spec 4.
## Context
Paddle is merchant of record: it owns checkout, tax, invoices, dunning and the
customer billing portal. This spec connects Paddle's subscription lifecycle to
the licence issuance functions spec 3 defines, and moves cloud signup off
sitesvc.
The central constraint, restated because every table below follows from it:
**licences are offline-verified, so nothing Paddle says can revoke one early.**
Cancellation takes effect when the licence expires. Self Hosted is annual-only to
bound that window; the alternative — a customer holding a valid key for eleven
months after cancelling a monthly plan — is not acceptable.
## Goals
1. A catalog in Paddle sandbox, promotable to production by configuration alone.
2. Webhooks that issue and renew licences reliably, including under retries and
out-of-order delivery.
3. Cloud signup owned by one service instead of two.
## Non-goals
- Building any part of billing Paddle already provides.
- Usage-based or metered pricing. Tiers are flat.
- Proration logic. Paddle handles money; we react to the resulting subscription
state.
## Design
### Catalog
Three products, created in **sandbox** first. Production is a configuration
change: the same `plans` rows carry different `paddle_product_id` and
`paddle_price_ids`, selected by `PADDLE_ENV`.
| Product | Prices | Notes |
|---|---|---|
| Vantage Free | monthly, £0 | Yes, a real £0 subscription. It gives every account a Paddle customer, a lifecycle, and an upgrade path with no special-case code. |
| Vantage Professional | monthly, annual | Cloud |
| Vantage Self Hosted | **annual only** | No monthly price exists, so the offline-revocation window is at most a year |
**No price ID is ever hard-coded.** They live in `plans.paddle_price_ids` and are
edited through the staff UI. A price change in Paddle is a data edit, not a
deploy.
`custom_data` on every checkout carries `{ account_id, instance_id, tier }`. This
is what lets a webhook route without a lookup table, and it is why the
self-hosted flow creates the instance record *before* checkout completes.
### Checkout
Paddle Checkout, overlay mode, in the admin site.
**Cloud upgrade** — instance exists, `instance_id` in `custom_data`, existing
Paddle customer reused.
**Self-hosted purchase** — the instance does not exist yet. Order:
```
Customer creates an admin-site account (verified email)
Account row created, then an admin_instances row with status awaiting_link
and a generated placeholder instance record
Checkout opened with account_id and that instance row's id in custom_data
subscription.created fires → subscription recorded, status awaiting_link,
NO licence issued
Customer pastes their install's UUID → instance_id set, status active
→ licence issued and delivered
```
The instance row exists before payment so the webhook has something to attach to.
The licence is not issued until the UUID is known, because a licence with no
instance to bind to cannot be signed — spec 1 has no unbound licence.
A customer who pays and never links has a subscription and no licence. Spec 4's
staff dashboard flags `awaiting_link` older than 48 hours, and a reminder email
goes out at 24 hours and 72 hours. This is the most likely place for a paying
customer to get stuck, so it gets active chasing rather than a support queue.
### Webhooks
`POST /api/paddle/webhook`, signature-verified with `PADDLE_WEBHOOK_SECRET`.
An unsigned or badly signed request is rejected `401` and logged — never
processed.
**Idempotency is mandatory.** Paddle retries. Every event ID is recorded in
`paddle_events` with a unique index before processing; a duplicate returns `200`
without acting. `200` on duplicates matters — returning an error would make
Paddle retry a message we have already handled, forever.
| Event | Action |
|---|---|
| `subscription.created` | Record the subscription. Cloud: issue and inject. Self-hosted: leave `awaiting_link`, issue nothing. |
| `subscription.updated` | Tier or term changed: issue a replacement licence at the new tier, supersede the old. Cloud injects; self-hosted emails a new blob and flags the site. Reflects Paddle's resulting state; no proration maths here. |
| `subscription.canceled` | Mark `cancelled`. **No licence action.** The current licence runs to expiry, then the instance degrades per spec 2. |
| `subscription.past_due` | Mark `past_due`, notify the customer, flag for staff. Licence untouched. Dunning is Paddle's job; ours is not to punish a retryable card failure. |
| `transaction.completed` where the transaction is a subscription renewal | Issue the next term's licence, supersede, inject or email. Reset `RelinkCount`. |
| `transaction.payment_failed` | Record for staff visibility. No licence action. |
| `customer.updated` | Sync `billing_email` onto the account. |
Out-of-order delivery is handled by making every handler a function of the
subscription's *current* state as reported in the event payload, rather than of
the transition. An `updated` arriving before its `created` creates the
subscription row and proceeds.
Renewal licences are issued with a **3-day grace** past the period end (spec 3),
so a webhook delayed by hours never produces a gap in coverage.
**Webhook failures must be visible.** Every failed handler writes to
`admin_audit` and appears on the staff dashboard. A licence that silently failed
to issue is a customer who paid and got nothing.
### Cancellation, stated plainly
When a customer cancels:
- Paddle stops billing at period end.
- We issue no further licences.
- Their current licence keeps working until it expires — up to a month for
Professional monthly, up to a year for Self Hosted.
- On expiry the instance degrades per spec 2: monitors keep running, changes stop.
This is documented in the terms and shown on the cancellation confirmation
screen, because a customer who cancels and sees their instance keep working
should understand why rather than assume the cancellation failed.
### Signup migration off sitesvc
Cloud signup currently lives in sitesvc: `site_pending_signups`, a verification
email, and provisioning on link click. It now needs to also create an Account, a
Paddle customer, a Free subscription and a licence.
**Signup moves to the admin backend.** The form stays on the marketing site where
customers find it, but it posts to admin instead of sitesvc. sitesvc keeps the
contact form only.
The reason is the one `CLAUDE.md` already names: provisioning logic duplicated
across services drifts. Spec 0a removed the second copy; adding signup to admin
while leaving it in sitesvc would create a third.
New flow, preserving every property of the current one:
```
Marketing site form → POST /api/signup on admin
→ pending record, password bcrypt cost 12, token 32 random bytes,
only the SHA-256 hash stored, 24h expiry, TTL index
→ verification email
Link opened → FindOneAndDelete the pending record (atomic, before provisioning)
→ shared.CreateInstance + shared.CreateUser in the control plane
→ Account created
→ Paddle customer created, Free subscription created
→ Free licence issued and injected
→ redirect to APP_LOGIN_URL with {slug} filled in
```
Properties that must survive, verified by test:
- Nothing written to `instances` or `users` until the link is opened.
- `FindOneAndDelete` before provisioning, so a double-clicked link cannot create
two instances.
- Instance rollback if the owner insert fails, refusing to delete an instance
that has users.
- Re-submitting for the same address replaces the pending record.
- Rate limited to 3 signups per IP per hour, plus the honeypot field.
Two failure modes are new, because provisioning now spans two systems:
- **Paddle customer creation fails** — the instance and user are already created.
Complete the signup, record the account with an empty `PaddleCustomerID`, issue
the Free licence anyway, and flag for staff. A new customer must never be
blocked from signing in by a billing-system hiccup.
- **Licence issuance fails** — the instance exists with no licence and is
read-only. Flagged for staff, and the 15-minute reconciliation job (spec 3)
retries. The customer can log in and sees the licence banner.
Both resolve toward "the customer gets in", because a signup that half-fails
silently is worse than either outcome.
sitesvc changes: signup, verify, `site_pending_signups` and the provisioning
calls are deleted. `SITE_API_URL` gains a sibling for the admin endpoint, or the
marketing site posts signup to `ADMIN_API_URL` directly — the latter, so the two
form targets are explicit rather than implied.
### Configuration
| Variable | Required | Notes |
|---|---|---|
| `PADDLE_ENV` | yes | `sandbox` or `production`; selects which price IDs the plans table serves |
| `PADDLE_API_KEY` | yes | server-side API |
| `PADDLE_CLIENT_TOKEN` | yes | browser checkout; baked into the admin site build |
| `PADDLE_WEBHOOK_SECRET` | yes | signature verification. Boot fails without it — an unverified webhook endpoint is an endpoint anyone can issue licences through |
| `APP_LOGIN_URL` | yes | moved from sitesvc; `{slug}` template |
### Cutover
Signup migration is the only user-visible switch:
1. Deploy admin with signup enabled; sitesvc still serving its own.
2. Point the marketing site's form at admin. Deploy.
3. Let sitesvc's outstanding pending signups expire naturally — 24 hours — while
its verify endpoint stays live. **Do not delete the collection until it is
empty**, or someone's verification link breaks.
4. Deploy sitesvc with signup removed.
## Testing
**Webhooks:**
1. Each event type produces its documented action against a mock Paddle payload.
2. Replaying an event ID is a no-op returning `200`.
3. A bad signature is rejected `401` and processes nothing.
4. `subscription.updated` before `subscription.created` creates the subscription
and applies the update.
5. `subscription.canceled` issues nothing and leaves the current licence intact.
6. `past_due` leaves the licence intact and flags the account.
7. Renewal issues the next term, supersedes, resets `RelinkCount`, and the new
`ExpiresAt` is period end plus 3 days.
8. A handler failure writes to `admin_audit` and surfaces on the dashboard.
**Checkout:**
9. `custom_data` round-trips account, instance and tier through to the webhook.
10. Self-hosted checkout leaves the instance `awaiting_link` with no licence.
11. Linking after checkout issues the licence.
**Signup:**
12. Nothing is written to `instances` or `users` before the link is opened.
13. A double-clicked verification link creates exactly one instance.
14. Owner-insert failure rolls the instance back; rollback refuses an instance
with users.
15. Re-submitting replaces the pending record and invalidates the earlier link.
16. Rate limit and honeypot both reject.
17. Paddle customer creation failure still completes signup and issues the Free
licence.
18. Licence issuance failure still lets the user log in, showing the banner.
19. Expired pending records are dropped by the TTL index.
## Verification before merge
1. Full suite green.
2. Against Paddle **sandbox**, end to end for each tier: checkout with a test
card, confirm the licence is issued, confirm the instance reports `valid`.
3. Trigger a sandbox renewal and confirm the next term's licence arrives and is
injected.
4. Cancel in sandbox and confirm the licence keeps working to expiry, then the
instance degrades correctly — monitors still running.
5. Replay every webhook from Paddle's dashboard and confirm no duplicate licences
are created.
6. Full signup end to end through admin, then confirm the new user can log into
their control-plane instance and sees a valid Free licence.
7. Confirm sitesvc's pending-signup collection is empty before its signup code is
removed.
## Risks
| Risk | Mitigation |
|---|---|
| Duplicate licences from webhook retries | Unique index on event ID, checked before processing |
| Webhook missed entirely | 15-minute reconciliation job (spec 3) compares subscription state against issued licences |
| Cancellation not enforceable until expiry | Accepted, bounded by term; Self Hosted annual-only; stated in terms and on the cancellation screen |
| Signup cutover breaks in-flight verification links | Staged cutover; sitesvc's verify stays live until its collection is empty |
| Sandbox price IDs reaching production | `PADDLE_ENV` selects them from the plans table; environment badge in the admin site |
| Webhook endpoint unauthenticated | Signature verification mandatory; boot fails without the secret |
| Customer pays and never links | Reminder emails at 24h and 72h, staff dashboard alert at 48h |
@@ -0,0 +1,286 @@
# Spec 0a — Shared Module Extraction
Date: 2026-07-24
Status: Design approved, not implemented
Ships: independently. No dependency on any other licensing spec.
## Context
Vantage is three independent Go modules: `server`, `sitesvc`, `agent`. There is no
root `go.mod` and no `go.work`.
`sitesvc` writes into the same MongoDB collections the control plane reads, but
cannot import the control plane, so it carries hand-copied duplicates:
- `sitesvc/internal/models/models.go``Org` and `User` mirrored field for field
- `sitesvc/internal/provision/provision.go``Slugify`, `ReservedSlugs`,
`MinSlugLength`, `MaxSlugLength`, `BcryptCost`, slug-collision rules
Both files carry comments saying they must be changed in lockstep with the
control plane, and `CLAUDE.md` names the hazard explicitly: nothing enforces the
match. **The duplication has already drifted.** The control plane's `CreateOrg`
resolves slug collisions with an inline `fmt.Sprintf("%s-%d", base, i)` loop,
while sitesvc exposes the same rule as a separate `NextSlug(base, attempt)`
helper. They currently agree by luck, not by construction.
The licensing programme adds a fourth service (`admin`) that writes the license
blob onto the same tenant document. Adding a third copy of these rules is not
acceptable. This spec removes the duplication before any licensing code is
written.
This spec is a **pure refactor**. No database document changes. No behaviour
changes. Names stay as they are today (`Org`, `org_id`) — renaming happens in
spec 0b, deliberately kept separate so that a failed deploy has one suspect
rather than two.
## Goals
1. One authoritative definition of every document shape written by more than one
service.
2. One authoritative definition of provisioning rules (slug, bcrypt cost,
creation, rollback).
3. `sitesvc` keeps its independence from `server` — it depends on `shared`, not
on the control plane. The original design intent survives; only the copying
dies.
4. The agent is untouched.
## Non-goals
- Renaming anything. That is spec 0b.
- Moving control-plane-only models. `workflow.go`, `monitor.go`, `key.go`,
`server.go`, `secret.go`, `assignment.go`, `channel.go`, `console_session.go`,
`audit.go`, `org_oidc.go` stay in `server/internal/models`. Only the control
plane touches them, and hoisting them would make `shared` a dumping ground.
- Merging the repo into a single module.
## Design
### Module layout
```
vantage/
├── go.work # NEW: server, sitesvc, shared (NOT agent)
├── shared/ # NEW module: github.com/mrhid6/vantage/shared
│ ├── go.mod
│ ├── models/
│ │ ├── org.go # Org
│ │ ├── user.go # User, RoleOwner/RoleAdmin/RoleMember, ValidRole
│ │ └── settings.go # Settings, AlertSettings, EmailSettings, SecretsSettings
│ ├── provision/
│ │ ├── slug.go # Slugify, BaseSlug, NextSlug, ReservedSlugs, limits
│ │ ├── org.go # CreateOrg
│ │ ├── user.go # CreateUser, BcryptCost
│ │ └── rollback.go # RollbackOrg
│ └── indexes/
│ └── indexes.go # EnsureCoreIndexes
├── server/ # replace => ../shared
├── sitesvc/ # replace => ../shared
└── agent/ # untouched
```
`go.work`:
```
go 1.26
use (
./shared
./server
./sitesvc
)
```
Each consumer's `go.mod` also carries an explicit replace:
```
require github.com/mrhid6/vantage/shared v0.0.0
replace github.com/mrhid6/vantage/shared => ../shared
```
Both are needed. `go.work` makes editors, `go test ./...` and local tooling work
across modules. The `replace` directives make Docker builds work whether or not
`go.work` is present, and stop `go build` outside the workspace from silently
trying to resolve `shared` from the network.
`shared` depends only on `go.mongodb.org/mongo-driver/v2`,
`golang.org/x/crypto/bcrypt` and `github.com/google/uuid`. It must not import
gin, redis, guac or anything else from the control plane's tree — that is what
keeps sitesvc small.
### What moves
**`shared/models`** — the three documents written by more than one service:
| Type | From | Written by |
|---|---|---|
| `Org` | `server/internal/models/org.go` | server, sitesvc, later admin |
| `User` + role constants + `ValidRole` | `server/internal/models/user.go` | server, sitesvc |
| `Settings` and its sub-structs | `server/internal/models/settings.go` | server today; admin reads it later |
`Settings` moves now rather than later because spec 3's admin service reads it,
and moving it later would mean a second round of import churn across both
services.
`PendingSignup` does **not** move. Only sitesvc writes `site_pending_signups`,
and the control plane does not know the collection exists.
**`shared/provision`** — the rules, promoted from private helpers to a real API:
```go
const (
MinSlugLength = 3
MaxSlugLength = 40
BcryptCost = 12
)
var ReservedSlugs = map[string]bool{ /* www, api, app, admin, auth, install, static, _next, default */ }
func Slugify(name string) string
func BaseSlug(name string) (string, error) // validates length + reserved
func NextSlug(base string, attempt int) string
// CreateOrg resolves a free slug and inserts. The caller supplies the
// collection handle so shared does not own a Mongo connection.
func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error)
func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role string) (*models.User, error)
// RollbackOrg deletes an org only if it has no users. Refuses otherwise.
func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error
```
`shared.CreateOrg` becomes the single implementation. The control plane's
`services.CreateOrg` shrinks to a wrapper that calls it and then runs
`SeedDefaultSteps` — seeding stays in the server, because `shared` must not know
about workflow steps. sitesvc calls `shared.CreateOrg` directly and does not
seed, which is the behaviour it has today.
Note on the slug loop: it is count-then-insert and therefore racy. It is safe
only because of the unique index on `orgs.slug`. `CreateOrg` must keep handling
`mongo.IsDuplicateKeyError` and returning a clean error — moving the code must
not lose that. Document the reliance in a comment at the loop.
**`shared/indexes`** — `EnsureCoreIndexes(ctx, db)` declares the unique indexes
on `users.email` and `orgs.slug`. Both services call it at boot; creating an
existing index is a no-op. These indexes are a security property, not an
optimisation (see `CLAUDE.md`: `GetUserByEmail` does an unscoped `FindOne`, so
duplicates would break the OIDC cross-org guard), so the shared version is
**fatal on failure** for both callers.
Server-only index builders (`EnsureSettingsIndexes`, `EnsureSecretIndexes`,
`EnsureWorkflowIndexes`) stay in the server and keep their current
fatal/warn behaviour.
### What is deleted
- `sitesvc/internal/models/models.go` — reduced to `PendingSignup` only
- `sitesvc/internal/provision/` — deleted entirely
- `sitesvc/internal/store/store.go` — org/user creation replaced by calls into
`shared/provision`; pending-signup storage stays
### Docker and CI
Both Go Dockerfiles currently build with the module directory as context:
```dockerfile
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build ... ./cmd
```
A `replace => ../shared` cannot resolve from that context. Build contexts move
to the repo root:
```dockerfile
WORKDIR /src
COPY shared/go.mod shared/go.sum ./shared/
COPY server/go.mod server/go.sum ./server/
RUN cd server && go mod download
COPY shared/ ./shared/
COPY server/ ./server/
ARG VERSION=dev
RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
```
The two-stage copy keeps the dependency-download layer cached, which is the
reason the current Dockerfiles are written the way they are.
`.gitea/workflows/server-deploy.yml` must set `context: .` and
`file: server/Dockerfile` (and likewise for sitesvc) for the two Go images. The
`web` and `site` image builds are unaffected.
`agent-release.yml` is untouched. The agent is not in the workspace, has no
`replace`, and cross-compiles exactly as it does today.
### Error handling
No new error paths. `shared/provision` returns the same error strings the two
callers produce today so that API responses do not change. The one place to be
careful is wording: sitesvc says "organisation" and the control plane says
"organization". `shared` standardises on **"organisation"**; the control plane's
two error strings change spelling. This is user-visible in API error text and is
called out here so it is a decision rather than an accident.
## Testing
**No automated tests.** Decision taken 2026-07-24: the repo has no Go test suite
and one is not being started here. Verification is by compiler, `grep`, and
running both services end to end.
That places the whole weight on three manual checks, which the implementation
plan makes mandatory steps rather than suggestions:
1. **bson tag diff**`diff` the `bson:"…"` tags of each moved struct against
the originals. A changed tag orphans production data silently, and this is
the only thing that catches it.
2. **Slug behaviour walkthrough** — a throwaway `main` printing `Slugify`,
`BaseSlug` and `NextSlug` output for a fixed input table, compared against
expected output recorded in the plan.
3. **End-to-end agreement** — sign up through sitesvc against a scratch
database, open the verification link, then log into the control plane with
those credentials. This is the check that proves the two services still agree
about the documents they share. If it passes, the refactor worked.
Plus `grep` assertions that exactly one definition of `Slugify` and
`ReservedSlugs` survives repo-wide, and that no struct under `sitesvc/` carries
a `bson:"org_id"` tag.
## Verification before merge
Evidence required, not assertions:
1. `go build ./...` succeeds in `shared`, `server` and `sitesvc`.
2. `go vet ./...` clean in all three.
3. `docker build -f server/Dockerfile .` and `docker build -f sitesvc/Dockerfile .`
both succeed from the repo root.
4. `grep -r "org_id" sitesvc/` returns hits only in `PendingSignup` context and
`shared` imports — no local struct redefinitions.
5. End-to-end against a scratch database: sitesvc signup form → verification link
→ org and owner created → that owner logs into the control plane
successfully. This is the test that proves the two services still agree.
6. The agent still builds for `linux/amd64`, `linux/arm64` and `windows/amd64`.
## Rollout
Single release. `server` and `sitesvc` images must be deployed together — a skew
is harmless here (documents are unchanged) but there is no reason to split it.
No database migration. No downtime.
## Risks
| Risk | Mitigation |
|---|---|
| Docker context change breaks CI | Verified locally by building both images from root before pushing |
| Behaviour drift while moving `CreateOrg` | Unit tests written against current behaviour first, then the move |
| `shared` accumulating control-plane concerns | Explicit non-goals above; keep its `go.mod` dependency list to three entries and review any addition |
| Error-string spelling change | Called out as a decision; grep the web UI for hard-coded matches on the old strings |
## Follow-on
Spec 0b (`instance-rename`) becomes a rename inside one module plus its
consumers, rather than a rename across three independent copies. That is the
whole reason this spec goes first.
+70
View File
@@ -0,0 +1,70 @@
# Vantage Licensing Programme — Spec Index
Seven specs, designed 2026-07-24. Build in this order.
| # | Spec | Plan | Status |
|---|---|---|---|
| 0a | [shared-module](2026-07-24-shared-module-design.md) | [plan](../plans/2026-07-24-shared-module.md) | **shipped** |
| 0b | [instance-rename](2026-07-24-instance-rename-design.md) | [plan](../plans/2026-07-24-instance-rename.md) | **shipped**, migration verified on live |
| 1 | [licensing-core](2026-07-24-licensing-core-design.md) | [plan](../plans/2026-07-24-licensing-core.md) | **shipped** |
| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | **shipped**, no grandfathering — existing cloud instances are read-only until admin backfills |
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | planned |
| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | needs 3 |
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | needs 3 |
Specs 1 and 2 together give working licensing with licences cut by hand with
`lkctl` — no admin service needed. 4 and 5 can run in parallel once 3 lands.
4 and 5 can run in parallel once 3 lands.
## The shape
```
Account (admin only)
├── Instance 1 cloud vantage.hostxtra.co.uk/<slug> licence auto-injected
├── Instance 2 cloud licence auto-injected
└── Instance 3 self-hosted customer's own deployment licence pasted by hand
```
The control plane knows only **Instance**. Accounts exist solely in the admin
service, because a self-hosted instance has no row in the cloud database at all.
## Decisions that everything else follows from
**Licences are offline-verified signed blobs.** ECDSA P-384 with SHA-256 via
`github.com/hyperboloide/lk`, public key compiled into the server, no phone-home
anywhere. This buys air-gapped self-hosting and means no Vantage instance ever
depends on the licensing service being up. It costs revocation: a licence is
valid until it expires whatever Paddle later says. Self Hosted is annual-only to
bound that window.
**Every licence is bound to one instance UUID.** Self-hosted customers link their
UUID before the licence is signed, so there is no unbound licence and no claim
protocol.
**Expiry degrades, it does not break.** Monitors keep executing, alerts keep
firing, agents keep their keys, in-flight workflow runs finish. Mutations stop.
Deletes and OS-update application stay open so a customer is never trapped
over-limit or unpatched.
**Tiers are data, not code.** The server reads `Limits` and `Features` and never
branches on tier name. Tier contents live in the admin `plans` table and are
snapshotted into each issued licence, so editing a plan never rewrites history —
the same rule as `workflow_runs.steps_snapshot`.
| | Free | Professional | Self Hosted |
|---|---|---|---|
| deployment | cloud only | cloud | self-hosted |
| max servers | 3 | unlimited | unlimited |
| max secret groups | 1 | unlimited | unlimited |
| max channels | 1 | unlimited | unlimited |
| console | no | yes | yes |
| OIDC | no | yes | yes |
| term | monthly, £0 | monthly or annual | annual only |
Free is cloud-only by construction: it is only ever signed with
`deployment: "cloud"`, and verification rejects a deployment mismatch. There is
no server-side flag to edit. One Free instance per account.
**Existing cloud tenants** are grandfathered to Professional, one year out, by
migration `0005`.
+7
View File
@@ -0,0 +1,7 @@
go 1.26.4
use (
./server
./shared
./sitesvc
)
+18
View File
@@ -0,0 +1,18 @@
cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//uf3IB011ls=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
github.com/cncf/xds/go v0.0.0-20240318125728-8a4994d93e50/go.mod h1:5e1+Vvlzido69INQaVO6d87Qn543Xr6nooe9Kz7oBFM=
github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0=
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
Binary file not shown.
+138
View File
@@ -0,0 +1,138 @@
param(
[string]$ServerId,
[string]$Token,
[string]$ServerUrl,
[string]$InstallDir,
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
$logDir = Join-Path $env:ProgramData "vantage"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$log = Join-Path $logDir "install.log"
function Write-Log($msg) {
$line = "{0} {1}" -f (Get-Date -Format "s"), $msg
Add-Content -Path $log -Value $line
}
# Fail native-exe (nssm) calls loudly: check $LASTEXITCODE after each call
function Invoke-Native {
param([string]$File, [string[]]$Arguments)
Write-Log ("RUN: {0} {1}" -f $File, ($Arguments -join " "))
$out = & $File @Arguments 2>&1
if ($out) { Write-Log ("OUT: {0}" -f ($out -join "`n")) }
if ($LASTEXITCODE -ne 0) {
throw ("{0} exited {1}" -f $File, $LASTEXITCODE)
}
}
function Invoke-NativeSoft {
param([string]$File, [string[]]$Arguments)
Write-Log ("RUN(soft): {0} {1}" -f $File, ($Arguments -join " "))
# Native stderr merged via 2>&1 becomes terminating errors under
# ErrorActionPreference=Stop; force Continue in this scope so a benign nssm
# message (e.g. "service has not been started") never aborts setup.
$ErrorActionPreference = "Continue"
$out = & $File @Arguments 2>&1
if ($out) { Write-Log ("OUT: {0}" -f ($out -join "`n")) }
Write-Log ("EXIT: {0}" -f $LASTEXITCODE)
}
if ($Uninstall) {
try {
Write-Log "=== teardown start ==="
if (-not $InstallDir) { $InstallDir = $PSScriptRoot }
$nssm = Join-Path $InstallDir "nssm.exe"
if (Test-Path $nssm) {
Invoke-NativeSoft -File $nssm -Arguments @("stop", "VantageAgent")
Invoke-NativeSoft -File $nssm -Arguments @("remove", "VantageAgent", "confirm")
} else {
Write-Log "nssm.exe not found at $nssm - using sc.exe fallback"
Invoke-NativeSoft -File "sc.exe" -Arguments @("stop", "VantageAgent")
Invoke-NativeSoft -File "sc.exe" -Arguments @("delete", "VantageAgent")
}
Write-Log "=== teardown ok ==="
exit 0
}
catch {
Write-Log ("TEARDOWN ERROR: {0}" -f $_.Exception.Message)
# Never block uninstall
exit 0
}
}
try {
Write-Log "=== setup start ==="
Write-Log ("ServerId={0} ServerUrl={1} InstallDir={2}" -f $ServerId, $ServerUrl, $InstallDir)
$cfgDir = Join-Path $env:ProgramData "vantage"
New-Item -ItemType Directory -Force -Path $cfgDir | Out-Null
$cfgPath = Join-Path $cfgDir "config.yaml"
# Preserve existing config on upgrade. A MajorUpgrade re-runs this script with
# no SERVERID/TOKEN, so blindly rewriting would wipe the agent_token the agent
# persisted after Register(). Only (re)write when a ServerId is supplied
# (fresh install / explicit re-register).
if ((Test-Path $cfgPath) -and (-not $ServerId)) {
Write-Log "config.yaml exists and no ServerId supplied - preserving existing config (upgrade)"
}
else {
$cfg = @"
server_url: "$ServerUrl"
server_id: "$ServerId"
pre_reg_token: "$Token"
agent_token: ""
poll_interval: 30s
tls: true
"@
Set-Content -Path $cfgPath -Value $cfg -Encoding utf8
Write-Log "wrote $cfgPath"
# Lock down ACL: SYSTEM + Administrators only
Invoke-Native -File "icacls" -Arguments @($cfgPath, "/inheritance:r", "/grant:r", "SYSTEM:F", "Administrators:F")
}
if (-not $InstallDir) { $InstallDir = $PSScriptRoot }
$nssm = Join-Path $InstallDir "nssm.exe"
$exe = Join-Path $InstallDir "vantage-agent.exe"
if (-not (Test-Path $nssm)) { throw "nssm.exe not found at $nssm" }
if (-not (Test-Path $exe)) { throw "vantage-agent.exe not found at $exe" }
# Install only if the service isn't already registered (an upgrade may leave
# it in place). "nssm install" on an existing service errors otherwise.
$exists = Get-Service -Name "VantageAgent" -ErrorAction SilentlyContinue
if (-not $exists) {
Invoke-Native -File $nssm -Arguments @("install", "VantageAgent", $exe)
} else {
Write-Log "VantageAgent service already exists - updating binary path"
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "Application", $exe)
}
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "Start", "SERVICE_AUTO_START")
# Redirect service stdout/stderr to log files (nssm discards them otherwise)
# with online rotation at ~1MB.
$outLog = Join-Path $logDir "agent-stdout.log"
$errLog = Join-Path $logDir "agent-stderr.log"
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStdout", $outLog)
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStderr", $errLog)
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStdoutCreationDisposition", "4")
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppStderrCreationDisposition", "4")
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateFiles", "1")
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateOnline", "1")
Invoke-Native -File $nssm -Arguments @("set", "VantageAgent", "AppRotateBytes", "1048576")
# Service is freshly (re)installed and stopped here (teardown removed the old
# one on upgrade), so start it. "restart" would try to stop a not-running
# service and emit a stderr error.
Invoke-NativeSoft -File $nssm -Arguments @("start", "VantageAgent")
Write-Log "=== setup ok ==="
exit 0
}
catch {
Write-Log ("ERROR: {0}" -f $_.Exception.Message)
Write-Log ($_.ScriptStackTrace)
exit 1
}
+82
View File
@@ -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;
}
+14 -9
View File
@@ -1,24 +1,29 @@
# Build stage
#
# Context is the repository root, not server/, because server depends on the
# shared module through a replace directive.
FROM golang:1.26 AS builder
WORKDIR /app
WORKDIR /src
# Download dependencies first (layer cache)
COPY go.mod go.sum ./
RUN go mod download
# Manifests first so the dependency layer caches independently of source edits.
COPY shared/go.mod shared/go.sum ./shared/
COPY server/go.mod server/go.sum ./server/
RUN cd server && go mod download
# Copy source and build
COPY . .
COPY shared/ ./shared/
COPY server/ ./server/
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.Version=${VERSION}" -o /keymanager-server ./cmd
RUN cd server && 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"]
+78 -15
View File
@@ -7,52 +7,115 @@ 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 os.Getenv("GRPC_HOST") == "" {
log.Fatal("GRPC_HOST is required (host:port agents dial for gRPC)")
}
if err := db.Connect(mongoURI, dbName); err != nil {
log.Fatalf("failed to connect to MongoDB: %v", err)
}
log.Println("connected to MongoDB")
// Migrations 0001 to 0003 still speak the pre-rename shape (orgs, org_id),
// so they must run before 0004 renames everything underneath them.
if err := services.RunMigrations(); err != nil {
log.Fatalf("migration failed: %v", err)
}
// 0002 must precede 0003: 0003 can create a "default" org, which pushes
// 0002 into its ambiguous multi-org branch.
if err := services.MigrateSettingsOrg(); err != nil {
log.Fatalf("settings org migration failed: %v", err)
}
if err := services.MigrateMissedOrgScopes(); err != nil {
log.Fatalf("missed org scope migration failed: %v", err)
}
// 0004 renames orgs to instances. It must run BEFORE the index builders:
// EnsureAuthIndexes creates instances.slug, which would create an empty
// instances collection and make 0004 refuse to rename onto it.
migCtx, migCancel := context.WithTimeout(context.Background(), 10*time.Minute)
migErr := services.MigrateOrgToInstance(migCtx, db.Database)
migCancel()
if migErr != nil {
log.Fatalf("instance rename migration failed: %v", migErr)
}
assertCtx, assertCancel := context.WithTimeout(context.Background(), 30*time.Second)
assertErr := services.AssertNoScopedCollectionMissed(assertCtx, db.Database)
assertCancel()
if assertErr != nil {
log.Fatalf("scoped collection check failed: %v", assertErr)
}
if err := services.EnsureAuthIndexes(); err != nil {
log.Fatalf("failed to ensure auth indexes: %v", err)
}
if err := services.EnsureSecretIndexes(); err != nil {
log.Printf("warning: failed to ensure secret indexes: %v", err)
}
if err := services.EnsureSettingsIndexes(); err != nil {
log.Fatalf("failed to ensure settings indexes: %v", err)
}
if err := services.EnsureWorkflowIndexes(); err != nil {
log.Printf("warning: failed to ensure workflow indexes: %v", err)
}
if instanceIDs, err := services.ListInstanceIDs(); err != nil {
log.Printf("warning: failed to list instances for default step seeding: %v", err)
} else {
for _, instanceID := range instanceIDs {
if created, updated, err := services.SeedDefaultSteps(instanceID); err != nil {
log.Printf("warning: failed to seed default steps for instance %s: %v", instanceID, err)
} else {
log.Printf("default steps seeded for instance %s: %d created, %d updated", instanceID, 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)
}
log.Println("connected to Redis")
if err := auth.InitOIDC(context.Background()); err != nil {
log.Fatalf("failed to initialise OIDC: %v", err)
}
// Background goroutine to mark offline servers
go func() {
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)
}
}
}()
// Start gRPC server
go func() {
if err := grpcserver.StartGRPC(9090); err != nil {
log.Fatalf("gRPC server error: %v", err)
}
}()
// Start REST server
r := gin.Default()
monitorsched.Start(context.Background())
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
r.Use(corsMiddleware())
api.RegisterRoutes(r)
+19 -11
View File
@@ -1,17 +1,21 @@
module github.com/mrhid6/keymanager/server
module github.com/mrhid6/vantage/server
go 1.26
go 1.26.4
require (
github.com/coreos/go-oidc/v3 v3.18.0
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.20.1
go.mongodb.org/mongo-driver/v2 v2.2.2
github.com/wwt/guac v1.3.2
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
golang.org/x/oauth2 v0.36.0
google.golang.org/grpc v1.64.0
)
require github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
@@ -25,29 +29,33 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
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/compress v1.17.6 // 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/mrhid6/vantage/shared v0.0.0
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
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/mrhid6/vantage/shared => ../shared
+32 -19
View File
@@ -35,21 +35,26 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
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/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0=
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo=
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=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
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,26 +70,33 @@ 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=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
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=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
@@ -92,8 +104,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c=
go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
@@ -101,36 +113,37 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+100
View File
@@ -0,0 +1,100 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"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(auth.InstanceID(c))
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(auth.InstanceID(c), &ch)
if err != nil {
if limitStatus(c, err) {
return
}
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(auth.InstanceID(c), 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(auth.InstanceID(c), 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(auth.InstanceID(c), c.Param("id")); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "sent"})
}
+168
View File
@@ -0,0 +1,168 @@
package api
import (
"net"
"net/http"
"os"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
"github.com/wwt/guac"
)
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(auth.InstanceID(c), body.ServerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
sess, err := services.CreateConsoleSession(auth.InstanceID(c), 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(auth.InstanceID(c), 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(auth.InstanceID(c), sess.SessionID, body.SSHUsername); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
services.LogEvent(auth.InstanceID(c), "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",
})
}
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
}
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
}
instanceID := auth.InstanceID(c)
sess, err := services.GetConsoleSession(instanceID, sessionID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
if actor := actorFromCtx(c); actor != sess.User {
c.JSON(http.StatusForbidden, gin.H{"error": "session belongs to another user"})
return
}
if err := services.ConsumeSessionToken(instanceID, sessionID); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"})
return
}
srv, err := services.GetServer(auth.InstanceID(c), sess.ServerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
var privKey, passphrase string
if sess.Protocol == "ssh" && sess.KeyID != "" {
privKey, err = services.GetPrivateKey(auth.InstanceID(c), 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(instanceID, 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"
}
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(instanceID, sessionID)
}
wsServer.ServeHTTP(c.Writer, c.Request)
}
+206 -73
View File
@@ -4,27 +4,47 @@ 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)
// Auth endpoints (no session required)
r.GET("/auth/login", auth.HandleLogin)
r.GET("/auth/callback", auth.HandleCallback)
r.GET("/auth/logout", auth.HandleLogout)
r.GET("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup)
r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus)
r.POST("/auth/bootstrap", auth.HandleBootstrap)
r.POST("/auth/login", auth.HandleLocalLogin)
r.POST("/auth/logout", auth.HandleLogout)
r.GET("/auth/me", auth.HandleMe)
r.GET("/auth/oidc/start", auth.HandleOIDCStart)
r.GET("/auth/oidc/callback", auth.HandleOIDCCallback)
// API endpoints protected by session middleware
apiGroup := r.Group("/api")
apiGroup.Use(auth.Middleware())
// Deny by default: every non-GET route under /api is gated unless it is on
// the exemption list in licence.go. A route added later is covered because
// of where it is mounted, not because someone remembered.
apiGroup.Use(RequireActiveLicense())
{
apiGroup.GET("/license", getLicence)
apiGroup.POST("/license", auth.RequireRole("owner"), postLicence)
apiGroup.GET("/servers", listServers)
apiGroup.POST("/servers", createServer)
apiGroup.GET("/servers/new", newServer)
@@ -33,9 +53,28 @@ 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)
settings := apiGroup.Group("/settings")
settings.Use(auth.RequireRole("owner", "admin"))
{
settings.GET("", getSettings)
settings.PUT("", saveSettings)
settings.POST("/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,11 +82,29 @@ 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", RequireFeature("console"), consoleConnect)
apiGroup.GET("/console/tunnel", RequireFeature("console"), consoleTunnel)
registerWorkflowRoutes(apiGroup)
registerMonitorRoutes(apiGroup)
registerChannelRoutes(apiGroup)
instance := apiGroup.Group("/instance")
instance.Use(auth.RequireRole("owner", "admin"))
{
instance.GET("/users", listInstanceUsers)
instance.POST("/users", createInstanceUser)
instance.PUT("/users/:id/role", updateInstanceUserRole)
instance.DELETE("/users/:id", deleteInstanceUser)
instance.GET("/oidc", RequireFeature("oidc"), getInstanceOIDC)
instance.PUT("/oidc", RequireFeature("oidc"), putInstanceOIDC)
}
}
}
func listServers(c *gin.Context) {
servers, err := services.ListServers()
servers, err := services.ListServers(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -56,8 +113,11 @@ func listServers(c *gin.Context) {
}
func createServer(c *gin.Context) {
s, token, err := services.CreateServer()
s, token, err := services.CreateServer(auth.InstanceID(c))
if err != nil {
if limitStatus(c, err) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -69,44 +129,51 @@ func createServer(c *gin.Context) {
}
func newServer(c *gin.Context) {
s, token, err := services.CreateServer()
s, token, err := services.CreateServer(auth.InstanceID(c))
if err != nil {
if limitStatus(c, err) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
host := os.Getenv("PUBLIC_HOST")
if host == "" {
host = "https://keymanager.example.com"
}
host := publicHostFromRequest(c)
installCmd := fmt.Sprintf(
`curl -fsSL "%s/install?server_id=%s&token=%s" | bash`,
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,
})
}
func getServer(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(id)
s, err := services.GetServer(auth.InstanceID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
assignments, _ := services.GetAssignmentsWithKeysForServer(id)
assignments, _ := services.GetAssignmentsWithKeysForServer(auth.InstanceID(c), id)
// Build response matching ServerWithKeys shape expected by frontend
type serverResponse struct {
*models.Server
Keys interface{} `json:"keys"`
@@ -119,10 +186,16 @@ func getServer(c *gin.Context) {
func deleteServer(c *gin.Context) {
id := c.Param("id")
if err := services.DeleteServer(id); err != nil {
s, _ := services.GetServer(auth.InstanceID(c), id)
if err := services.DeleteServer(auth.InstanceID(c), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
hostname := id
if s != nil {
hostname = s.Hostname
}
services.LogEvent(auth.InstanceID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -141,7 +214,7 @@ func generateKey(c *gin.Context) {
body.Label = "generated"
}
s, err := services.GetServer(id)
s, err := services.GetServer(auth.InstanceID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -159,6 +232,7 @@ func generateKey(c *gin.Context) {
return
}
services.LogEvent(auth.InstanceID(c), "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,
@@ -167,7 +241,7 @@ func generateKey(c *gin.Context) {
}
func listKeys(c *gin.Context) {
keys, err := services.ListKeys()
keys, err := services.ListKeys(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -180,23 +254,25 @@ 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(auth.InstanceID(c), body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label))
c.JSON(http.StatusCreated, key)
}
func getPrivateKey(c *gin.Context) {
id := c.Param("id")
plaintext, err := services.GetPrivateKey(id)
plaintext, err := services.GetPrivateKey(auth.InstanceID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -206,13 +282,13 @@ func getPrivateKey(c *gin.Context) {
func getKey(c *gin.Context) {
id := c.Param("id")
key, err := services.GetKey(id)
key, err := services.GetKey(auth.InstanceID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
return
}
assignments, _ := services.GetAssignmentsWithServers(id)
assignments, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
type keyResponse struct {
*models.Key
@@ -226,10 +302,16 @@ func getKey(c *gin.Context) {
func deleteKey(c *gin.Context) {
id := c.Param("id")
if err := services.DeleteKey(id); err != nil {
k, _ := services.GetKey(auth.InstanceID(c), id)
if err := services.DeleteKey(auth.InstanceID(c), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
label := id
if k != nil {
label = k.Label
}
services.LogEvent(auth.InstanceID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -243,11 +325,12 @@ func assignKey(c *gin.Context) {
return
}
a, err := services.AssignKey(keyID, body.ServerID)
a, err := services.AssignKey(auth.InstanceID(c), keyID, body.ServerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
c.JSON(http.StatusCreated, a)
}
@@ -255,10 +338,11 @@ func revokeAssignment(c *gin.Context) {
keyID := c.Param("id")
serverID := c.Param("serverId")
if err := services.RevokeAssignment(keyID, serverID); err != nil {
if err := services.RevokeAssignment(auth.InstanceID(c), keyID, serverID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
c.JSON(http.StatusOK, gin.H{"revoked": true})
}
@@ -273,7 +357,7 @@ func getLatestAgentVersion(c *gin.Context) {
func updateAgent(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(id)
s, err := services.GetServer(auth.InstanceID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -284,12 +368,29 @@ func updateAgent(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), 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(auth.InstanceID(c), "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 +410,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
@@ -318,34 +419,76 @@ if [ -z "$LATEST" ]; then
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"
LATEST_ENCODED="${LATEST/\
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(auth.InstanceID(c), 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(auth.InstanceID(c))
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(auth.InstanceID(c), body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "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")
@@ -354,14 +497,7 @@ func handleInstallScript(c *gin.Context) {
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
publicHost := os.Getenv("PUBLIC_HOST")
if publicHost == "" {
publicHost = "keymanager.example.com"
}
grpcHost := os.Getenv("GRPC_HOST")
if grpcHost == "" {
grpcHost = publicHost
}
script := fmt.Sprintf(`#!/usr/bin/env bash
set -euo pipefail
@@ -369,9 +505,6 @@ set -euo pipefail
SERVER_ID="%s"
TOKEN="%s"
GITEA_HOST="%s"
KM_HOST="%s"
KM_HOST="${KM_HOST#https://}"
KM_HOST="${KM_HOST#http://}"
GRPC_HOST="%s"
GRPC_HOST="${GRPC_HOST#https://}"
GRPC_HOST="${GRPC_HOST#http://}"
@@ -384,7 +517,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
@@ -393,29 +526,29 @@ if [ -z "$LATEST" ]; then
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"
LATEST_ENCODED="${LATEST/\
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 +556,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,10 +574,10 @@ WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now keymanager-agent
systemctl enable --now vantage-agent
echo "keymanager-agent installed and started."
`, serverID, token, giteaHost, publicHost, grpcHost)
echo "vantage-agent installed and started."
`, serverID, token, giteaHost, grpcHost)
c.Header("Content-Type", "text/x-shellscript")
c.String(http.StatusOK, script)
+85
View File
@@ -0,0 +1,85 @@
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")
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)
}
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)
}
+155
View File
@@ -0,0 +1,155 @@
package api
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
func listInstanceUsers(c *gin.Context) {
users, err := services.ListUsers(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
}
func actorMayGrantOwner(c *gin.Context) bool {
return auth.Role(c) == models.RoleOwner
}
func createInstanceUser(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "email required"})
return
}
if body.Role == "" {
body.Role = models.RoleMember
}
if !models.ValidRole(body.Role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
return
}
if body.Role == models.RoleOwner && !actorMayGrantOwner(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can create another owner"})
return
}
u, err := services.CreateUser(auth.InstanceID(c), body.Email, body.Password, body.Role, "local")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, u)
}
func updateInstanceUserRole(c *gin.Context) {
var body struct {
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Role == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "role required"})
return
}
if !models.ValidRole(body.Role) {
c.JSON(http.StatusBadRequest, gin.H{"error": "role must be one of owner, admin, or member"})
return
}
instanceID, targetID := auth.InstanceID(c), c.Param("id")
if targetID == auth.UserID(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"})
return
}
target, err := services.GetUserInInstance(instanceID, targetID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if (body.Role == models.RoleOwner || target.Role == models.RoleOwner) && !actorMayGrantOwner(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can change owner roles"})
return
}
if err := services.UpdateUserRole(instanceID, targetID, body.Role); err != nil {
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func deleteInstanceUser(c *gin.Context) {
instanceID, targetID := auth.InstanceID(c), c.Param("id")
if targetID == auth.UserID(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"})
return
}
target, err := services.GetUserInInstance(instanceID, targetID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if target.Role == models.RoleOwner && !actorMayGrantOwner(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "only an owner can remove another owner"})
return
}
if err := services.DeleteUser(instanceID, targetID); err != nil {
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func orgUserErrStatus(err error) int {
if errors.Is(err, services.ErrLastOwner) {
return http.StatusConflict
}
return http.StatusInternalServerError
}
func getInstanceOIDC(c *gin.Context) {
cfg, err := services.GetInstanceOIDC(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusOK, gin.H{"enabled": false, "client_secret_set": false})
return
}
c.JSON(http.StatusOK, gin.H{
"instance_id": cfg.InstanceID,
"issuer": cfg.Issuer,
"client_id": cfg.ClientID,
"enabled": cfg.Enabled,
"updated_at": cfg.UpdatedAt,
"client_secret_set": cfg.ClientSecretEnc != "",
})
}
func putInstanceOIDC(c *gin.Context) {
var body struct {
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.SaveInstanceOIDC(auth.InstanceID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
auth.EvictOIDCProvider(auth.InstanceID(c))
c.JSON(http.StatusOK, gin.H{"saved": true})
}
+227
View File
@@ -0,0 +1,227 @@
package api
import (
"errors"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
"github.com/mrhid6/vantage/shared/license"
)
// licenceExemptPaths are routes that must work while a licence is expired or
// missing, because they are how a customer recovers or stays safe.
//
// /api/license pasting a valid licence is the way out of degraded mode
// apply-updates security patching is never paywalled
//
// All DELETE requests are exempt separately (see RequireActiveLicense): a
// customer downgraded below their current usage must be able to delete their
// way back under the cap.
var licenceExemptPaths = map[string]bool{
"/api/license": true,
}
// mutatingGETs are routes that change state despite their method. GET is
// otherwise always allowed through, so these have to be named explicitly:
// GET /api/servers/new mints a pre-registration token, which is a creation.
var mutatingGETs = map[string]bool{
"/api/servers/new": true,
}
func licenceExempt(c *gin.Context) bool {
if c.Request.Method == http.MethodDelete {
return true
}
if licenceExemptPaths[c.FullPath()] {
return true
}
if c.FullPath() == "/api/servers/:id/apply-updates" {
return true
}
return false
}
// RequireActiveLicense blocks mutating requests when the licence is not valid.
//
// Mounted on the /api group, so a route added tomorrow is gated because of where
// it lives rather than because someone remembered. GET and HEAD always pass —
// reading is never blocked.
func RequireActiveLicense() gin.HandlerFunc {
return func(c *gin.Context) {
if (c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead) &&
!mutatingGETs[c.FullPath()] {
c.Next()
return
}
if licenceExempt(c) {
c.Next()
return
}
st := services.GetLicenseState(auth.InstanceID(c))
if st.Active() {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "license_required",
"state": st.Status,
"reason": st.Reason,
})
}
}
// RequireFeature blocks a route when the licence does not grant a feature.
func RequireFeature(name string) gin.HandlerFunc {
return func(c *gin.Context) {
st := services.GetLicenseState(auth.InstanceID(c))
if st.Feature(name) {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "feature_unavailable",
"feature": name,
})
}
}
type licenceResponse struct {
InstanceID string `json:"instance_id"`
State license.State `json:"state"`
Reason string `json:"reason,omitempty"`
Tier string `json:"tier,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
DaysRemaining *int `json:"days_remaining,omitempty"`
Limits license.Limits `json:"limits"`
Features map[string]bool `json:"features"`
Usage licenceUsageResponse `json:"usage"`
Source string `json:"source"`
}
type licenceUsageResponse struct {
Servers int `json:"servers"`
SecretGroups int `json:"secret_groups"`
Channels int `json:"channels"`
}
func getLicence(c *gin.Context) {
instanceID := auth.InstanceID(c)
st := services.GetLicenseState(instanceID)
servers, groups, channels := services.LicenseUsage(instanceID)
resp := licenceResponse{
InstanceID: instanceID,
State: st.Status,
Reason: st.Reason,
Tier: st.Tier,
ExpiresAt: st.ExpiresAt,
Limits: st.Limits,
Features: st.Features,
Usage: licenceUsageResponse{Servers: servers, SecretGroups: groups, Channels: channels},
Source: st.Source,
}
if st.ExpiresAt != nil {
d := int(time.Until(*st.ExpiresAt).Hours() / 24)
resp.DaysRemaining = &d
}
c.JSON(http.StatusOK, resp)
}
var (
licencePostMu sync.Mutex
licencePostCounts = map[string][]time.Time{}
)
const licencePostLimit = 10
// licencePostAllowed permits 10 attempts per instance per hour.
func licencePostAllowed(instanceID string) bool {
cutoff := time.Now().Add(-time.Hour)
licencePostMu.Lock()
defer licencePostMu.Unlock()
kept := licencePostCounts[instanceID][:0]
for _, t := range licencePostCounts[instanceID] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= licencePostLimit {
licencePostCounts[instanceID] = kept
return false
}
licencePostCounts[instanceID] = append(kept, time.Now())
return true
}
func postLicence(c *gin.Context) {
instanceID := auth.InstanceID(c)
if !licencePostAllowed(instanceID) {
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Too many licence attempts. Try again later.",
})
return
}
var body struct {
Blob string `json:"blob"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "a licence key is required"})
return
}
st, err := services.StoreLicense(instanceID, body.Blob)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": licenceRejectionMessage(err.Error(), instanceID),
"reason": err.Error(),
})
return
}
services.LogEvent(instanceID, "license.updated", actorFromCtx(c), "", "",
"licence accepted (tier "+st.Tier+")")
c.JSON(http.StatusOK, gin.H{"state": st.Status, "tier": st.Tier, "expires_at": st.ExpiresAt})
}
// licenceRejectionMessage turns a machine reason into something a person can act
// on. The instance ID is included in the mismatch case because that is the one
// piece of information the customer needs and cannot guess.
func licenceRejectionMessage(reason, instanceID string) string {
switch reason {
case license.ReasonBadSignature:
return "This licence key is not valid. Check it was copied in full."
case license.ReasonDeploymentMismatch:
return "This licence is for Vantage Cloud and cannot be used on a self-hosted install."
case license.ReasonInstanceMismatch:
return "This licence was issued for a different instance. Your instance ID is " + instanceID + "."
case license.ReasonNoLicense:
return "No licence key was provided."
default:
return "This licence could not be accepted."
}
}
// limitStatus maps a LimitError to a 403 body. Handlers that create countable
// resources call this so the UI gets a machine-readable limit name.
func limitStatus(c *gin.Context, err error) bool {
var le *services.LimitError
if !errors.As(err, &le) {
return false
}
c.JSON(http.StatusForbidden, gin.H{
"error": "limit_exceeded",
"limit": le.Limit,
"current": le.Current,
"max": le.Max,
})
return true
}
+158
View File
@@ -0,0 +1,158 @@
package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"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(auth.InstanceID(c))
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(auth.InstanceID(c), &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(auth.InstanceID(c), 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(auth.InstanceID(c), 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(auth.InstanceID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
func getMonitorIncidents(c *gin.Context) {
m, err := services.GetMonitor(auth.InstanceID(c), 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
}
incidents, err := services.ListIncidents(auth.InstanceID(c), 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) {
m, err := services.GetMonitor(auth.InstanceID(c), 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
}
since := time.Now().Add(-30 * 24 * time.Hour)
rollups, err := services.UptimeRollups(auth.InstanceID(c), c.Param("id"), since)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rollups)
}
+51
View File
@@ -0,0 +1,51 @@
package api
import (
"net"
"strings"
"github.com/gin-gonic/gin"
)
func publicHostFromRequest(c *gin.Context) string {
host := c.Request.Host
if h := firstForwarded(c.GetHeader("X-Forwarded-Host")); h != "" {
host = h
}
if host == "" {
return "https://vantage.example.com"
}
return schemeFor(c, host) + "://" + host
}
func schemeFor(c *gin.Context, host string) string {
if p := firstForwarded(c.GetHeader("X-Forwarded-Proto")); p != "" {
return p
}
if c.Request.TLS != nil {
return "https"
}
if isLoopback(host) {
return "http"
}
return "https"
}
func firstForwarded(v string) string {
if v == "" {
return ""
}
return strings.TrimSpace(strings.Split(v, ",")[0])
}
func isLoopback(host string) bool {
h, _, err := net.SplitHostPort(host)
if err != nil {
h = host
}
if h == "localhost" || strings.HasSuffix(h, ".localhost") {
return true
}
ip := net.ParseIP(h)
return ip != nil && ip.IsLoopback()
}
+197
View File
@@ -0,0 +1,197 @@
package api
import (
"fmt"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
)
var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
func validName(s string) bool {
return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s)
}
const ctxSecretsInstanceKey = "km_secrets_instance"
func secretsReadAuth() gin.HandlerFunc {
return func(c *gin.Context) {
const prefix = "Bearer "
authHeader := c.GetHeader("Authorization")
if len(authHeader) <= len(prefix) || !strings.EqualFold(authHeader[:len(prefix)], prefix) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return
}
instanceID, ok := services.ResolveSecretsReadToken(authHeader[len(prefix):])
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set(ctxSecretsInstanceKey, instanceID)
c.Next()
}
}
func esoGetGroup(c *gin.Context) {
group := c.Param("group")
instanceID := c.GetString(ctxSecretsInstanceKey)
if instanceID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
values, err := services.GetSecretGroupDecrypted(instanceID, 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(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, groups)
}
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(auth.InstanceID(c), body.Group, body.Values); err != nil {
if limitStatus(c, err) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), 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})
}
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(auth.InstanceID(c), group, values); err != nil {
if limitStatus(c, err) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), group, body.Key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), group, key); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), group); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
c.JSON(http.StatusOK, gin.H{"token": token})
}
+359
View File
@@ -0,0 +1,359 @@
package api
import (
"fmt"
"io"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"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
}
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)
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()
instanceID := auth.InstanceID(c)
for {
sendNew()
if serverRunTerminal(instanceID, runID, serverID) {
sendNew()
_, _ = c.Writer.WriteString("event: done\ndata: end\n\n")
flusher.Flush()
return
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func serverRunTerminal(instanceID, runID, serverID string) bool {
r, err := services.GetRun(instanceID, 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
}
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(auth.InstanceID(c))
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(auth.InstanceID(c))
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(auth.InstanceID(c), s)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), c.Param("id"), s); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), 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(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "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
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(auth.InstanceID(c), body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name))
c.JSON(http.StatusCreated, out)
}
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(auth.InstanceID(c))
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(auth.InstanceID(c), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), 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(auth.InstanceID(c), c.Param("id"), w); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
updated, err := services.GetWorkflow(auth.InstanceID(c), 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(auth.InstanceID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "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(auth.InstanceID(c), 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(auth.InstanceID(c), 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(auth.InstanceID(c), c.Param("runId")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.InstanceID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
c.JSON(http.StatusOK, gin.H{"cancelled": true})
}
+74
View File
@@ -0,0 +1,74 @@
package auth
import (
"os"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
type cachedInstance struct {
instance *models.Instance
at time.Time
}
var (
instanceCacheMu sync.Mutex
instanceCache = map[string]cachedInstance{}
)
const instanceCacheTTL = 60 * time.Second
func appRootLabel() string {
if v := os.Getenv("APP_ROOT_LABEL"); v != "" {
return strings.ToLower(v)
}
return "vantage"
}
func hostSlug(host string) string {
host = strings.ToLower(host)
if i := strings.IndexByte(host, ':'); i >= 0 {
host = host[:i]
}
root := appRootLabel()
parts := strings.Split(host, ".")
if len(parts) < 3 {
return ""
}
if parts[1] != root {
return ""
}
if parts[0] == root || parts[0] == "www" {
return ""
}
return parts[0]
}
func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
slug := hostSlug(c.Request.Host)
if slug == "" {
return nil, false
}
instanceCacheMu.Lock()
if e, ok := instanceCache[slug]; ok && time.Since(e.at) < instanceCacheTTL {
instanceCacheMu.Unlock()
return e.instance, e.instance != nil
}
instanceCacheMu.Unlock()
inst, err := services.GetInstanceBySlug(slug)
if err != nil || inst == nil {
return nil, false
}
instanceCacheMu.Lock()
instanceCache[slug] = cachedInstance{instance: inst, at: time.Now()}
instanceCacheMu.Unlock()
return inst, true
}
+155
View File
@@ -0,0 +1,155 @@
package auth
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/services"
)
func SetSessionCookie(c *gin.Context, sessionID string) {
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookieName,
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionTTL.Seconds()),
})
}
func HandleLocalLogin(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "email and password required"})
return
}
u, err := services.GetUserByEmail(body.Email)
if err != nil || !services.VerifyPassword(u, body.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
_ = services.TouchLastLogin(u.UserID)
SetSessionCookie(c, sessionID)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func HandleBootstrapStatus(c *gin.Context) {
var (
n int64
err error
instName string
)
if inst, ok := InstanceFromHost(c); ok {
n, err = services.CountInstanceUsers(inst.InstanceID)
instName = inst.Name
} else {
n, err = services.CountUsers()
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0, "instance_name": instName})
}
func HandleBootstrap(c *gin.Context) {
n, err := services.CountUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if n > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "setup already complete"})
return
}
var body struct {
InstanceName string `json:"instance_name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceName == "" || body.Email == "" || len(body.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_name, email, and password (>=8 chars) required"})
return
}
instanceCount, err := services.CountInstances()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var inst *models.Instance
switch instanceCount {
case 0:
inst, err = services.CreateInstance(body.InstanceName)
case 1:
var existing *models.Instance
existing, err = services.FirstInstance()
if err == nil {
inst, err = services.AdoptInstance(existing.InstanceID, body.InstanceName)
}
default:
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf(
"cannot bootstrap: %d organizations already exist but no users do; "+
"create the owner against the intended inst rather than through setup, "+
"or remove the unintended orgs and retry", instanceCount)})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, err := services.CreateUser(inst.InstanceID, body.Email, body.Password, "owner", "local")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
SetSessionCookie(c, sessionID)
c.JSON(http.StatusCreated, gin.H{
"instance": inst,
"slug": inst.Slug,
"instance_id": inst.InstanceID,
})
}
func HandleMe(c *gin.Context) {
cookie, err := c.Request.Cookie(sessionCookieName)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
sess, err := GetSession(c.Request.Context(), cookie.Value)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
return
}
if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID {
c.JSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"})
return
}
inst, _ := services.GetInstance(sess.InstanceID)
c.JSON(http.StatusOK, gin.H{"user": sess, "instance": inst})
}
+45 -5
View File
@@ -14,13 +14,42 @@ func GetSessionFromContext(c *gin.Context) *Session {
return sess
}
func InstanceID(c *gin.Context) string {
if s := GetSessionFromContext(c); s != nil {
return s.InstanceID
}
return ""
}
func Role(c *gin.Context) string {
if s := GetSessionFromContext(c); s != nil {
return s.Role
}
return ""
}
func UserID(c *gin.Context) string {
if s := GetSessionFromContext(c); s != nil {
return s.UserID
}
return ""
}
func RequireRole(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
r := Role(c)
for _, want := range roles {
if r == want {
c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "insufficient role"})
}
}
func Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !authEnabled {
c.Next()
return
}
cookie, err := c.Request.Cookie(sessionCookieName)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
@@ -33,7 +62,18 @@ func Middleware() gin.HandlerFunc {
return
}
if sess.InstanceID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session has no organization"})
return
}
c.Set(ctxSessionKey, sess)
if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"})
return
}
c.Next()
}
}
+96 -83
View File
@@ -2,123 +2,154 @@ package auth
import (
"context"
"log"
"fmt"
"net/http"
"os"
"strings"
"sync"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/services"
"golang.org/x/oauth2"
)
var (
oidcProvider *oidc.Provider
oauth2Cfg *oauth2.Config
authEnabled bool
provMu sync.Mutex
provCache = map[string]*oidc.Provider{}
)
func InitOIDC(ctx context.Context) error {
issuer := os.Getenv("OIDC_ISSUER")
if issuer == "" {
log.Println("OIDC_ISSUER not set; authentication disabled")
return nil
}
p, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return err
}
oidcProvider = p
oauth2Cfg = &oauth2.Config{
ClientID: os.Getenv("OIDC_CLIENT_ID"),
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
RedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
Endpoint: p.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
authEnabled = true
log.Println("OIDC authentication enabled")
return nil
func EvictOIDCProvider(instanceID string) {
provMu.Lock()
delete(provCache, instanceID)
provMu.Unlock()
}
func Enabled() bool { return authEnabled }
func redirectURL(c *gin.Context) string {
scheme := "https"
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
scheme = "http"
}
return fmt.Sprintf("%s://%s/auth/oidc/callback", scheme, c.Request.Host)
}
func HandleLogin(c *gin.Context) {
state, err := randomHex(16)
func providerForInstance(ctx context.Context, c *gin.Context, instanceID string) (*oidc.Provider, *oauth2.Config, error) {
cfg, err := services.GetInstanceOIDC(instanceID)
if err != nil || !cfg.Enabled {
return nil, nil, fmt.Errorf("inst SSO not configured")
}
secret, err := services.GetInstanceOIDCSecret(instanceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state generation failed"})
return nil, nil, err
}
provMu.Lock()
p := provCache[instanceID]
provMu.Unlock()
if p == nil {
p, err = oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, nil, err
}
provMu.Lock()
provCache[instanceID] = p
provMu.Unlock()
}
return p, &oauth2.Config{
ClientID: cfg.ClientID, ClientSecret: secret,
RedirectURL: redirectURL(c), Endpoint: p.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}, nil
}
func HandleOIDCStart(c *gin.Context) {
inst, ok := InstanceFromHost(c)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown instance host"})
return
}
if err := SaveState(c.Request.Context(), state); err != nil {
// Losing the feature stops new SSO logins. It deliberately does not touch
// session validation, so nobody is evicted mid-session.
if !services.GetLicenseState(inst.InstanceID).Feature("oidc") {
c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
return
}
ctx := c.Request.Context()
_, oauthCfg, err := providerForInstance(ctx, c, inst.InstanceID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
state, err := randomHex(16)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state gen failed"})
return
}
if err := SaveStateInstance(ctx, state, inst.InstanceID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"})
return
}
c.Redirect(http.StatusFound, oauth2Cfg.AuthCodeURL(state))
c.Redirect(http.StatusFound, oauthCfg.AuthCodeURL(state))
}
func HandleCallback(c *gin.Context) {
func HandleOIDCCallback(c *gin.Context) {
ctx := c.Request.Context()
if !ConsumeState(ctx, c.Query("state")) {
instanceID, ok := ConsumeStateInstance(ctx, c.Query("state"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
return
}
token, err := oauth2Cfg.Exchange(ctx, c.Query("code"))
provider, oauthCfg, err := providerForInstance(ctx, c, instanceID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
token, err := oauthCfg.Exchange(ctx, c.Query("code"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token exchange failed"})
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "missing id_token"})
return
}
verifier := oidcProvider.Verifier(&oidc.Config{ClientID: oauth2Cfg.ClientID})
idToken, err := verifier.Verify(ctx, rawIDToken)
idToken, err := provider.Verifier(&oidc.Config{ClientID: oauthCfg.ClientID}).Verify(ctx, rawIDToken)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token verification failed"})
return
}
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
if err := idToken.Claims(&claims); err != nil {
if err := idToken.Claims(&claims); err != nil || claims.Email == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims extraction failed"})
return
}
email := strings.ToLower(claims.Email)
u, err := services.GetUserByEmail(email)
if err != nil {
u, err = services.CreateUser(instanceID, email, "", "member", "oidc")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
return
}
} else if u.InstanceID != instanceID {
c.JSON(http.StatusForbidden, gin.H{"error": "email belongs to a different organization"})
return
}
sessionID, err := SaveSession(ctx, &Session{
UserID: claims.Sub,
Email: claims.Email,
Name: claims.Name,
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, Name: claims.Name,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookieName,
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionTTL.Seconds()),
})
frontendURL := os.Getenv("PUBLIC_HOST")
if frontendURL == "" {
frontendURL = "/"
}
c.Redirect(http.StatusFound, frontendURL)
_ = services.TouchLastLogin(u.UserID)
SetSessionCookie(c, sessionID)
c.Redirect(http.StatusFound, "/")
}
func HandleLogout(c *gin.Context) {
@@ -134,21 +165,3 @@ func HandleLogout(c *gin.Context) {
})
c.Redirect(http.StatusFound, "/")
}
func HandleMe(c *gin.Context) {
if !authEnabled {
c.JSON(http.StatusOK, gin.H{"auth_enabled": false})
return
}
cookie, err := c.Request.Cookie(sessionCookieName)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
sess, err := GetSession(c.Request.Context(), cookie.Value)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired"})
return
}
c.JSON(http.StatusOK, sess)
}
+13 -8
View File
@@ -16,9 +16,11 @@ const sessionPrefix = "km:session:"
const statePrefix = "km:state:"
type Session struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
UserID string `json:"user_id"`
InstanceID string `json:"instance_id"`
Role string `json:"role"`
Email string `json:"email"`
Name string `json:"name"`
}
var rdb *redis.Client
@@ -69,11 +71,14 @@ func DeleteSession(ctx context.Context, id string) error {
return rdb.Del(ctx, sessionPrefix+id).Err()
}
func SaveState(ctx context.Context, state string) error {
return rdb.Set(ctx, statePrefix+state, "1", 10*time.Minute).Err()
func SaveStateInstance(ctx context.Context, state, instanceID string) error {
return rdb.Set(ctx, statePrefix+state, instanceID, 10*time.Minute).Err()
}
func ConsumeState(ctx context.Context, state string) bool {
n, err := rdb.Del(ctx, statePrefix+state).Result()
return err == nil && n > 0
func ConsumeStateInstance(ctx context.Context, state string) (string, bool) {
instanceID, err := rdb.GetDel(ctx, statePrefix+state).Result()
if err != nil || instanceID == "" {
return "", false
}
return instanceID, true
}
+215
View File
@@ -0,0 +1,215 @@
package checker
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"os"
"strings"
"time"
)
const (
TypeHTTP = "http"
TypeTCP = "tcp"
TypeICMP = "icmp"
TypeTLS = "tls"
)
type Spec struct {
Type string
URL string
Host string
Port int
Method string
ExpectedStatus int
Keyword string
TLSWarnDays int
Insecure bool
TimeoutSec int
}
type Result struct {
Up bool
LatencyMs int
Message string
CertExpiry *time.Time
}
func (s Spec) timeout() time.Duration {
t := s.TimeoutSec
if t <= 0 || t > 10 {
t = 10
}
return time.Duration(t) * time.Second
}
func Run(ctx context.Context, s Spec) Result {
switch s.Type {
case TypeHTTP:
return runHTTP(ctx, s)
case TypeTCP:
return runTCP(ctx, s)
case TypeICMP:
return runICMP(ctx, s)
case TypeTLS:
return runTLS(ctx, s)
default:
return Result{Message: "unknown check type: " + s.Type}
}
}
func runHTTP(ctx context.Context, s Spec) Result {
method := s.Method
if method == "" {
method = http.MethodGet
}
expect := s.ExpectedStatus
if expect == 0 {
expect = 200
}
client := &http.Client{Timeout: s.timeout()}
if s.Insecure {
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
}
start := time.Now()
req, err := http.NewRequestWithContext(ctx, method, s.URL, nil)
if err != nil {
return Result{Message: err.Error()}
}
resp, err := client.Do(req)
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
}
defer resp.Body.Close()
res := Result{LatencyMs: msSince(start), Up: true}
if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
exp := resp.TLS.PeerCertificates[0].NotAfter
res.CertExpiry = &exp
}
if resp.StatusCode != expect {
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: fmt.Sprintf("status %d (want %d)", resp.StatusCode, expect)}
}
if s.Keyword != "" {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if !strings.Contains(string(body), s.Keyword) {
return Result{LatencyMs: res.LatencyMs, CertExpiry: res.CertExpiry, Message: "keyword not found"}
}
}
return res
}
func runTCP(ctx context.Context, s Spec) Result {
addr := net.JoinHostPort(s.Host, fmt.Sprint(s.Port))
start := time.Now()
d := net.Dialer{Timeout: s.timeout()}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
}
conn.Close()
return Result{Up: true, LatencyMs: msSince(start)}
}
func runTLS(ctx context.Context, s Spec) Result {
port := s.Port
if port == 0 {
port = 443
}
addr := net.JoinHostPort(s.Host, fmt.Sprint(port))
start := time.Now()
d := net.Dialer{Timeout: s.timeout()}
conn, err := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: s.Host})
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
}
defer conn.Close()
certs := conn.ConnectionState().PeerCertificates
if len(certs) == 0 {
return Result{LatencyMs: msSince(start), Message: "no peer certificate"}
}
exp := certs[0].NotAfter
res := Result{LatencyMs: msSince(start), CertExpiry: &exp}
warn := s.TLSWarnDays
if warn <= 0 {
warn = 14
}
remaining := time.Until(exp)
if remaining <= 0 {
res.Message = "certificate expired"
return res
}
if remaining <= time.Duration(warn)*24*time.Hour {
res.Message = fmt.Sprintf("certificate expires in %d days", int(remaining.Hours()/24))
return res
}
res.Up = true
return res
}
func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) }
func runICMP(ctx context.Context, s Spec) Result {
dst, err := net.ResolveIPAddr("ip4", s.Host)
if err != nil {
return Result{Message: err.Error()}
}
conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return Result{Message: "icmp socket: " + err.Error()}
}
defer conn.Close()
id := os.Getpid() & 0xffff
pkt := icmpEcho(id, 1)
deadline := time.Now().Add(s.timeout())
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
deadline = d
}
_ = conn.SetDeadline(deadline)
start := time.Now()
if _, err := conn.WriteTo(pkt, dst); err != nil {
return Result{Message: err.Error()}
}
reply := make([]byte, 1500)
for {
n, peer, err := conn.ReadFrom(reply)
if err != nil {
return Result{LatencyMs: msSince(start), Message: "no reply"}
}
if n < 28 || peer.String() != dst.String() {
continue
}
if reply[20] == 0 {
return Result{Up: true, LatencyMs: msSince(start)}
}
}
}
func icmpEcho(id, seq int) []byte {
b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)}
cs := icmpChecksum(b)
b[2] = byte(cs >> 8)
b[3] = byte(cs)
return b
}
func icmpChecksum(b []byte) uint16 {
var sum uint32
for i := 0; i < len(b)-1; i += 2 {
sum += uint32(b[i])<<8 | uint32(b[i+1])
}
if len(b)%2 == 1 {
sum += uint32(b[len(b)-1]) << 8
}
for sum>>16 != 0 {
sum = (sum & 0xffff) + (sum >> 16)
}
return ^uint16(sum)
}
+1 -2
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
)
// JSONCodec is a gRPC codec that uses JSON encoding.
type JSONCodec struct{}
func (JSONCodec) Marshal(v interface{}) ([]byte, error) {
@@ -16,5 +15,5 @@ func (JSONCodec) Unmarshal(data []byte, v interface{}) error {
}
func (JSONCodec) Name() string {
return "proto" // override default proto codec name so gRPC uses it
return "proto"
}
-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})
}
+504
View File
@@ -0,0 +1,504 @@
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"`
}
type PackageUpdate struct {
Name string `json:"name"`
CurrentVersion string `json:"current_version,omitempty"`
NewVersion string `json:"new_version"`
}
type ReportUpdatesRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Updates []PackageUpdate `json:"updates"`
}
type ReportUpdatesResponse struct{}
type CPUReport struct {
Model string `json:"model,omitempty"`
Cores int `json:"cores,omitempty"`
UsagePct float64 `json:"usage_pct"`
Load1 float64 `json:"load1,omitempty"`
}
type MemReport struct {
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
}
type PartitionReport struct {
Device string `json:"device"`
Mountpoint string `json:"mountpoint"`
Fstype string `json:"fstype,omitempty"`
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
}
type InventoryReport struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
IncludeStatic bool `json:"include_static"`
CPU *CPUReport `json:"cpu,omitempty"`
Memory *MemReport `json:"memory,omitempty"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
}
type InventoryReportResponse struct{}
type MonitorSpec struct {
MonitorId string `json:"monitor_id"`
Type string `json:"type"`
URL string `json:"url,omitempty"`
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
Method string `json:"method,omitempty"`
ExpectedStatus int `json:"expected_status,omitempty"`
Keyword string `json:"keyword,omitempty"`
TLSWarnDays int `json:"tls_warn_days,omitempty"`
Insecure bool `json:"insecure,omitempty"`
IntervalSec int `json:"interval_sec"`
Retries int `json:"retries"`
}
type SyncMonitorsRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
}
type SyncMonitorsResponse struct {
Monitors []MonitorSpec `json:"monitors,omitempty"`
}
type CheckResult struct {
MonitorId string `json:"monitor_id"`
Up bool `json:"up"`
LatencyMs int `json:"latency_ms"`
Message string `json:"message,omitempty"`
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
}
type ReportChecksRequest struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Results []CheckResult `json:"results,omitempty"`
}
type ReportChecksResponse struct{}
type ApplyUpdatesCmd struct{}
type 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"`
}
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 string `json:"workspace_id,omitempty"`
}
type StepResult struct {
CommandId string `json:"command_id"`
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
OutputEnv map[string]string `json:"output_env,omitempty"`
}
type StepOutputChunk struct {
CommandId string `json:"command_id"`
Seq uint64 `json:"seq"`
Data []byte `json:"data,omitempty"`
Eof bool `json:"eof,omitempty"`
}
type Vantage_CommandStreamServer interface {
Send(*ServerCommand) error
Recv() (*AgentMessage, error)
grpc.ServerStream
}
type keyManagerCommandStreamServer struct {
grpc.ServerStream
}
func (s *keyManagerCommandStreamServer) Send(m *ServerCommand) error {
return s.ServerStream.SendMsg(m)
}
func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
m := new(AgentMessage)
if err := s.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type Vantage_CommandStreamClient interface {
Send(*AgentMessage) error
Recv() (*ServerCommand, error)
grpc.ClientStream
}
type vantageCommandStreamClient struct {
grpc.ClientStream
}
func (c *vantageCommandStreamClient) Send(m *AgentMessage) error {
return c.ClientStream.SendMsg(m)
}
func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
m := new(ServerCommand)
if err := c.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
type 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")
}
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
}
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})
}
+130 -16
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,21 @@ 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) {
// Register carries no licence check, deliberately.
//
// A server row only ever comes from CreateServer, which checks the cap before
// issuing a pre-registration token. By the time an agent calls Register its row
// already exists, so counting here would count the caller itself: an instance
// sitting exactly at its cap would reject the very agent it just authorised, and
// every re-registration after a reinstall would fail too.
//
// The cap is enforced where rows are created, which is the only place it can be
// enforced correctly.
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 +44,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 +54,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,27 +66,104 @@ 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)
key, err := services.CreateKey(srv.InstanceID, req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "")
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to store key: %v", err)
}
// Auto-assign to the generating server
if _, err := services.AssignKey(key.KeyID, srv.ServerID); err != nil {
if _, err := services.AssignKey(srv.InstanceID, key.KeyID, srv.ServerID); err != nil {
log.Printf("failed to auto-assign generated key: %v", err)
}
return &pb.UploadKeyResponse{KeyId: key.KeyID}, nil
}
func (s *keyManagerServer) CommandStream(stream pb.KeyManager_CommandStreamServer) error {
// First message authenticates the agent and signals readiness.
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.InstanceID, 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) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if 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(srv.InstanceID, srv.ServerID, 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 {
msg, err := stream.Recv()
if err != nil {
return status.Errorf(codes.InvalidArgument, "expected initial auth message: %v", err)
@@ -89,8 +184,6 @@ func (s *keyManagerServer) CommandStream(stream pb.KeyManager_CommandStreamServe
log.Printf("agent %s connected command stream", srv.ServerID)
defer log.Printf("agent %s disconnected command stream", srv.ServerID)
// Drain inbound results in the background so client Send calls never block.
// UploadGeneratedKey handles the real storage; these are just confirmation logs.
go func() {
for {
m, err := stream.Recv()
@@ -101,6 +194,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 +229,19 @@ func StartGRPC(port int) error {
return fmt.Errorf("failed to listen: %w", err)
}
s := grpc.NewServer()
pb.RegisterKeyManagerServer(s, &keyManagerServer{})
s := grpc.NewServer(
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 20 * time.Second,
PermitWithoutStream: false,
}),
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: 45 * time.Second,
Timeout: 10 * time.Second,
}),
)
pb.RegisterVantageServer(s, &vantageServer{})
log.Printf("gRPC server listening on :%d", port)
return s.Serve(lis)
+5 -4
View File
@@ -8,8 +8,9 @@ import (
type Assignment struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
KeyID string `bson:"key_id" json:"key_id"`
ServerID string `bson:"server_id" json:"server_id"`
AssignedAt time.Time `bson:"assigned_at" json:"assigned_at"`
RevokedAt *time.Time `bson:"revoked_at,omitempty" json:"revoked_at,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
KeyID string `bson:"key_id" json:"key_id"`
ServerID string `bson:"server_id" json:"server_id"`
AssignedAt time.Time `bson:"assigned_at" json:"assigned_at"`
RevokedAt *time.Time `bson:"revoked_at,omitempty" json:"revoked_at,omitempty"`
}
+18
View File
@@ -0,0 +1,18 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AuditEvent struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
InstanceID string `bson:"instance_id" json:"instance_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"`
}
+26
View File
@@ -0,0 +1,26 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
const (
ChannelWebhook = "webhook"
ChannelSMTP = "smtp"
ChannelDiscord = "discord"
ChannelSlack = "slack"
ChannelTelegram = "telegram"
)
type NotificationChannel struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
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"`
}
+27
View File
@@ -0,0 +1,27 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type ConsoleSession struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
SessionID string `bson:"session_id" json:"session_id"`
ServerID string `bson:"server_id" json:"server_id"`
Protocol string `bson:"protocol" json:"protocol"`
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 *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:"-"`
}
+7
View File
@@ -0,0 +1,7 @@
package models
import shared "github.com/mrhid6/vantage/shared/models"
// Instance is defined in the shared module because sitesvc and the admin
// control plane write the same documents.
type Instance = shared.Instance
+17
View File
@@ -0,0 +1,17 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type InstanceOIDC struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
Issuer string `bson:"issuer" json:"issuer"`
ClientID string `bson:"client_id" json:"client_id"`
ClientSecretEnc string `bson:"client_secret_enc,omitempty" json:"-"`
Enabled bool `bson:"enabled" json:"enabled"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
+4 -1
View File
@@ -8,13 +8,16 @@ import (
type Key struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
KeyID string `bson:"key_id" json:"key_id"`
Label string `bson:"label" json:"label"`
PublicKey string `bson:"public_key" json:"public_key"`
Fingerprint string `bson:"fingerprint" json:"fingerprint"`
Source string `bson:"source" json:"source"` // uploaded | generated
Source string `bson:"source" json:"source"`
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"`
}
+77
View File
@@ -0,0 +1,77 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
const (
MonitorHTTP = "http"
MonitorTCP = "tcp"
MonitorICMP = "icmp"
MonitorTLS = "tls"
)
const (
StatusUp = "up"
StatusDown = "down"
StatusPending = "pending"
)
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"`
}
type MonitorState struct {
Status string `bson:"status" json:"status"`
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"`
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"`
InstanceID string `bson:"instance_id" json:"instance_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"`
Target MonitorTarget `bson:"target" json:"target"`
IntervalSec int `bson:"interval_sec" json:"interval_sec"`
Runner string `bson:"runner" json:"runner"`
Retries int `bson:"retries" json:"retries"`
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 {
InstanceID string `bson:"instance_id" json:"instance_id"`
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 {
InstanceID string `bson:"instance_id" json:"instance_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
PeriodStart time.Time `bson:"period_start" json:"period_start"`
Checks int `bson:"checks" json:"checks"`
UpCount int `bson:"up_count" json:"up_count"`
SumLatency int64 `bson:"sum_latency" json:"sum_latency"`
}
+22
View File
@@ -0,0 +1,22 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Secret struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
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"`
}
type GroupSummary struct {
Group string `json:"group"`
KeyCount int `json:"key_count"`
UpdatedAt time.Time `json:"updated_at"`
}
+58 -13
View File
@@ -6,17 +6,62 @@ 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"`
InstanceID string `bson:"instance_id" json:"instance_id"`
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"`
}
+10
View File
@@ -0,0 +1,10 @@
package models
import shared "github.com/mrhid6/vantage/shared/models"
type (
Settings = shared.Settings
AlertSettings = shared.AlertSettings
EmailSettings = shared.EmailSettings
SecretsSettings = shared.SecretsSettings
)
+13
View File
@@ -0,0 +1,13 @@
package models
import shared "github.com/mrhid6/vantage/shared/models"
type User = shared.User
const (
RoleOwner = shared.RoleOwner
RoleAdmin = shared.RoleAdmin
RoleMember = shared.RoleMember
)
func ValidRole(role string) bool { return shared.ValidRole(role) }
+103
View File
@@ -0,0 +1,103 @@
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:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
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"`
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"`
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"`
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:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
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"`
}
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"`
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"`
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:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
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"`
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"`
}
+105
View File
@@ -0,0 +1,105 @@
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"
)
const reloadInterval = 30 * time.Second
type runner struct {
monitorID string
intervalSec int
cancel context.CancelFunc
}
func Start(ctx context.Context) {
go loop(ctx)
}
func loop(ctx context.Context) {
active := map[string]*runner{}
var mu sync.Mutex
// Monitors run regardless of licence state, deliberately.
//
// A customer whose card failed must not lose the ability to know their
// infrastructure is on fire. Creating and editing monitors is blocked by the
// API gate; executing the ones that already exist is not.
sync := func() {
monitors, err := services.ListServerScheduledMonitors()
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()
for id, r := range active {
m, ok := want[id]
if !ok || m.IntervalSec != r.intervalSec {
r.cancel()
delete(active, id)
}
}
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.IngestServerScheduledResult(m.MonitorID, res); err != nil {
log.Printf("monitorsched: ingest %s: %v", m.MonitorID, err)
}
}
run()
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
run()
}
}
}
+57
View File
@@ -0,0 +1,57 @@
package notify
import (
"fmt"
"time"
"github.com/mrhid6/vantage/server/internal/models"
)
type Event struct {
MonitorName string
Type string
OldStatus string
NewStatus string
Message string
Time time.Time
}
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
}
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)
}
}
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(),
})
}
+70
View File
@@ -0,0 +1,70 @@
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
}
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()})
}
+90
View File
@@ -0,0 +1,90 @@
package notify
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
"time"
"github.com/mrhid6/vantage/server/internal/models"
)
const smtpTimeout = 15 * time.Second
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))
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()
}
+161
View File
@@ -0,0 +1,161 @@
package notify
import (
"fmt"
"html"
"mime/multipart"
"net/textproto"
"strings"
"github.com/mrhid6/vantage/server/internal/models"
)
const (
colBg = "#0f1117"
colSurface = "#1a1d27"
colSurface2 = "#232635"
colBorder = "#2e3147"
colText = "#e8eaf0"
colTextMuted = "#9095a8"
colAccent = "#6366f1"
colSuccess = "#22c55e"
colDanger = "#ef4444"
)
func statusColor(status string) string {
switch status {
case models.StatusUp:
return colSuccess
case models.StatusDown:
return colDanger
default:
return colTextMuted
}
}
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
}
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,
)
}
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")
}
+51
View File
@@ -0,0 +1,51 @@
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(instanceID, eventType, actor, serverID, keyID, details string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
event := models.AuditEvent{
InstanceID: instanceID,
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(instanceID string, 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{"instance_id": instanceID}, 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
}
+115
View File
@@ -0,0 +1,115 @@
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(instanceID string) ([]models.NotificationChannel, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"instance_id": instanceID}, 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(instanceID, 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, "instance_id": instanceID}).Decode(&ch)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
return &ch, nil
}
func GetChannels(instanceID string, 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{"instance_id": instanceID, "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 validateChannelIDs(instanceID string, channelIDs []string) error {
for _, id := range channelIDs {
ch, err := GetChannel(instanceID, id)
if err != nil {
return err
}
if ch == nil {
return errors.New("channel " + id + " not found")
}
}
return nil
}
func CreateChannel(instanceID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) {
if err := CheckChannelLimit(instanceID); err != nil {
return nil, err
}
ctx, cancel := monCtx()
defer cancel()
ch.InstanceID = instanceID
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(instanceID, channelID string, upd bson.M) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID}, bson.M{"$set": upd})
return err
}
func DeleteChannel(instanceID, channelID string) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID})
return err
}
func TestChannel(instanceID, channelID string) error {
ch, err := GetChannel(instanceID, channelID)
if err != nil {
return err
}
if ch == nil {
return errors.New("channel not found")
}
return notify.Test(*ch)
}
+236
View File
@@ -0,0 +1,236 @@
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) {
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) }
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
}
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)
}
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(instanceID, serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s := &models.ConsoleSession{
InstanceID: instanceID,
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(instanceID, 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, "instance_id": instanceID}).Decode(&s); err != nil {
return nil, err
}
return &s, nil
}
func StashConsoleRDPCreds(instanceID, 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, "instance_id": instanceID},
bson.M{"$set": bson.M{"rdp_user_enc": u, "rdp_pass_enc": p}},
)
return err
}
func ConsumeConsoleRDPCreds(instanceID, sessionID string) (username, password string, err error) {
s, err := GetConsoleSession(instanceID, 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, "instance_id": instanceID},
bson.M{"$unset": bson.M{"rdp_user_enc": "", "rdp_pass_enc": ""}},
)
return username, password, nil
}
func SetConsoleSSHUser(instanceID, 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, "instance_id": instanceID},
bson.M{"$set": bson.M{"ssh_username": username}})
return err
}
func ConsumeSessionToken(instanceID, 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, "instance_id": instanceID, "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(instanceID, 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, "instance_id": instanceID, "ended_at": nil},
bson.M{"$set": bson.M{"ended_at": now}},
)
return err
}
+41
View File
@@ -0,0 +1,41 @@
package services
import (
"context"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/indexes"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// EnsureAuthIndexes declares the indexes tenant isolation depends on.
//
// It lives here rather than in migrate.go so that the org-to-instance rename
// could touch it without touching migrations 0001 to 0003, which deliberately
// still speak the pre-rename shape.
//
// It MUST run after MigrateOrgToInstance. Creating the instances.slug index
// first would create an empty instances collection, and migration 0004 refuses
// to rename orgs when instances already exists.
func EnsureAuthIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// users.email and instances.slug are declared in the shared module so the
// control plane and sitesvc cannot disagree about them.
if err := indexes.EnsureCoreIndexes(ctx, db.Database); err != nil {
return err
}
// instance_oidc is control-plane only, so its index stays here.
if _, err := db.Col("instance_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "instance_id", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
return nil
}
+6 -2
View File
@@ -22,7 +22,7 @@ func encryptionKey() ([]byte, error) {
return key, nil
}
func encryptPrivateKey(plaintext string) (string, error) {
func encryptString(plaintext string) (string, error) {
key, err := encryptionKey()
if err != nil {
return "", err
@@ -43,7 +43,7 @@ func encryptPrivateKey(plaintext string) (string, error) {
return hex.EncodeToString(sealed), nil
}
func decryptPrivateKey(ciphertextHex string) (string, error) {
func decryptString(ciphertextHex string) (string, error) {
key, err := encryptionKey()
if err != nil {
return "", err
@@ -70,3 +70,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) }
+90
View File
@@ -0,0 +1,90 @@
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"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func DefaultStepsDir() string {
dir := os.Getenv("VANTAGE_DEFAULT_STEPS_DIR")
if dir == "" {
dir = filepath.Join("data", "default-steps")
}
_ = os.MkdirAll(dir, 0700)
return dir
}
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 = provision.Slugify(s.Name)
if s.Slug == "" {
continue
}
out = append(out, s)
}
return out, nil
}
func SeedDefaultSteps(instanceID string) (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{"instance_id": instanceID, "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{
"instance_id": instanceID,
"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
}
+29 -18
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 {
@@ -17,13 +17,10 @@ type commandDispatcher struct {
channels map[string]chan *pb.ServerCommand
}
// Dispatcher is the singleton command dispatcher used by both the gRPC server
// and the REST API to push commands to connected agents.
var Dispatcher = &commandDispatcher{
channels: make(map[string]chan *pb.ServerCommand),
}
// Connect registers an agent's command channel. Returns the channel to drain.
func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand {
ch := make(chan *pb.ServerCommand, 16)
d.mu.Lock()
@@ -32,14 +29,12 @@ func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand {
return ch
}
// Disconnect removes the agent's channel on stream close.
func (d *commandDispatcher) Disconnect(serverID string) {
d.mu.Lock()
delete(d.channels, serverID)
d.mu.Unlock()
}
// IsConnected reports whether an agent is currently holding a CommandStream.
func (d *commandDispatcher) IsConnected(serverID string) bool {
d.mu.RLock()
_, ok := d.channels[serverID]
@@ -62,7 +57,20 @@ func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) err
}
}
// KeyGenParams carries all options for a generate-key command.
func DispatchRunStep(serverID, commandID string, cmd *pb.RunStepCmd) error {
return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, RunStep: cmd})
}
func DispatchCleanupWorkspace(serverID, workspaceID string) {
if !Dispatcher.IsConnected(serverID) {
return
}
_ = Dispatcher.dispatch(serverID, &pb.ServerCommand{
CommandId: uuid.New().String(),
CleanupWorkspace: &pb.CleanupWorkspaceCmd{WorkspaceId: workspaceID},
})
}
type KeyGenParams struct {
Label string
KeyType string
@@ -71,15 +79,13 @@ type KeyGenParams struct {
Comment string
}
// GetLatestAgentVersion queries the Gitea API for the latest agent/v* release tag
// and returns just the version number (e.g. "1.2.3").
func GetLatestAgentVersion() (string, error) {
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/keymanager/releases?limit=20", giteaHost)
resp, err := http.Get(url) //nolint:gosec
url := fmt.Sprintf("https://%s/api/v1/repos/mrhid6/vantage/releases?limit=20", giteaHost)
resp, err := http.Get(url)
if err != nil {
return "", fmt.Errorf("fetch releases: %w", err)
}
@@ -103,8 +109,6 @@ func GetLatestAgentVersion() (string, error) {
return "", fmt.Errorf("no agent release found")
}
// DispatchUpdateAgent sends an update command to the named server's agent.
// It fetches the latest version from Gitea and includes the download base URL.
func DispatchUpdateAgent(serverID string) (string, error) {
if !Dispatcher.IsConnected(serverID) {
return "", fmt.Errorf("agent is not connected to the command stream")
@@ -134,8 +138,17 @@ func DispatchUpdateAgent(serverID string) (string, error) {
return version, nil
}
// 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 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)
}
func DispatchDeleteKey(serverID, label string) {
if !Dispatcher.IsConnected(serverID) {
return
@@ -145,13 +158,11 @@ func DispatchDeleteKey(serverID, label string) {
DeleteKey: &pb.DeleteKeyCmd{Label: label},
}
if err := Dispatcher.dispatch(serverID, cmd); err != nil {
// Non-fatal: agent will clean up files on next manual intervention or reinstall.
_ = err
}
}
// DispatchGenerateKey sends a generate-key command to the named server's agent.
// Returns the command ID that can be used to correlate the agent's result.
func DispatchGenerateKey(serverID string, p KeyGenParams) (string, error) {
if !Dispatcher.IsConnected(serverID) {
return "", fmt.Errorf("agent is not connected to the command stream")
+50
View File
@@ -0,0 +1,50 @@
package services
import (
"context"
"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 GetInstanceOIDC(instanceID string) (*models.InstanceOIDC, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.InstanceOIDC
err := db.Col("instance_oidc").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&o)
if err != nil {
return nil, err
}
return &o, nil
}
func GetInstanceOIDCSecret(instanceID string) (string, error) {
o, err := GetInstanceOIDC(instanceID)
if err != nil {
return "", err
}
return decryptString(o.ClientSecretEnc)
}
func SaveInstanceOIDC(instanceID, issuer, clientID, clientSecret string, enabled bool) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
set := bson.M{
"instance_id": instanceID, "issuer": issuer, "client_id": clientID,
"enabled": enabled, "updated_at": time.Now(),
}
if clientSecret != "" {
enc, err := encryptString(clientSecret)
if err != nil {
return err
}
set["client_secret_enc"] = enc
}
_, err := db.Col("instance_oidc").UpdateOne(ctx,
bson.M{"instance_id": instanceID}, bson.M{"$set": set},
options.UpdateOne().SetUpsert(true))
return err
}
+122
View File
@@ -0,0 +1,122 @@
package services
import (
"context"
"fmt"
"log"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
func GetInstance(instanceID string) (*models.Instance, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.Instance
err := db.Col("instances").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&o)
if err != nil {
return nil, err
}
return &o, nil
}
func GetInstanceBySlug(slug string) (*models.Instance, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.Instance
err := db.Col("instances").FindOne(ctx, bson.M{"slug": slug}).Decode(&o)
if err != nil {
return nil, err
}
return &o, nil
}
func ListInstanceIDs() ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("instances").Find(ctx, bson.M{})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var orgs []models.Instance
if err := cursor.All(ctx, &orgs); err != nil {
return nil, err
}
ids := make([]string, 0, len(orgs))
for _, o := range orgs {
ids = append(ids, o.InstanceID)
}
return ids, nil
}
func CountInstances() (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return db.Col("instances").CountDocuments(ctx, bson.M{})
}
func FirstInstance() (*models.Instance, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.Instance
if err := db.Col("instances").FindOne(ctx, bson.M{}).Decode(&o); err != nil {
return nil, err
}
return &o, nil
}
func AdoptInstance(instanceID, name string) (*models.Instance, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
set := bson.M{"name": name}
slug := provision.Slugify(name)
if len(slug) > provision.MaxSlugLength {
slug = slug[:provision.MaxSlugLength]
}
if len(slug) >= provision.MinSlugLength && !provision.ReservedSlugs[slug] {
n, err := db.Col("instances").CountDocuments(ctx, bson.M{"slug": slug, "instance_id": bson.M{"$ne": instanceID}})
if err != nil {
return nil, err
}
if n == 0 {
set["slug"] = slug
}
}
if _, err := db.Col("instances").UpdateOne(ctx, bson.M{"instance_id": instanceID}, bson.M{"$set": set}); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, fmt.Errorf("instance slug already taken")
}
return nil, err
}
return GetInstance(instanceID)
}
// CreateInstance creates an organisation and seeds its default workflow steps.
//
// The creation rules live in shared/provision because sitesvc creates
// organisations too. Seeding stays here: shared must not know about workflow
// steps.
func CreateInstance(name string) (*models.Instance, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
o, err := provision.CreateInstance(ctx, db.Database, name)
if err != nil {
return nil, err
}
if created, updated, err := SeedDefaultSteps(o.InstanceID); err != nil {
log.Printf("warning: failed to seed default steps for new org %s: %v", o.InstanceID, err)
} else {
log.Printf("default steps seeded for new org %s: %d created, %d updated", o.InstanceID, created, updated)
}
return o, nil
}
+50
View File
@@ -0,0 +1,50 @@
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"
)
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
}
+62 -30
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,10 +33,12 @@ 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(instanceID, label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
key := &models.Key{
InstanceID: instanceID,
KeyID: uuid.NewString(),
Label: label,
PublicKey: publicKey,
@@ -52,6 +54,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()
@@ -63,12 +72,12 @@ func CreateKey(label, publicKey, source, generatedByServerID, privateKey string)
return key, nil
}
func GetKey(keyID string) (*models.Key, error) {
func GetKey(instanceID, keyID string) (*models.Key, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var key models.Key
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID}).Decode(&key)
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key)
if err != nil {
return nil, err
}
@@ -76,12 +85,12 @@ func GetKey(keyID string) (*models.Key, error) {
return &key, nil
}
func GetPrivateKey(keyID string) (string, error) {
func GetPrivateKey(instanceID, 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 {
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key); err != nil {
return "", err
}
if key.PrivateKeyEncrypted == "" {
@@ -90,16 +99,30 @@ func GetPrivateKey(keyID string) (string, error) {
return decryptPrivateKey(key.PrivateKeyEncrypted)
}
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"`
}
func ListKeys() ([]KeyWithCount, error) {
func ListKeys(instanceID string) ([]KeyWithCount, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("keys").Find(ctx, bson.M{})
cursor, err := db.Col("keys").Find(ctx, bson.M{"instance_id": instanceID})
if err != nil {
return nil, err
}
@@ -114,27 +137,28 @@ func ListKeys() ([]KeyWithCount, error) {
for _, k := range keys {
setKeyMeta(&k)
count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{
"key_id": k.KeyID,
"revoked_at": nil,
"instance_id": instanceID,
"key_id": k.KeyID,
"revoked_at": nil,
})
result = append(result, KeyWithCount{Key: k, AssignedCount: int(count)})
}
return result, nil
}
func DeleteKey(keyID string) error {
func DeleteKey(instanceID, keyID 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 {
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key); err != nil {
return err
}
if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID}); err != nil {
if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}); err != nil {
return err
}
if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID}); err != nil {
if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}); err != nil {
return err
}
@@ -144,22 +168,30 @@ func DeleteKey(keyID string) error {
return nil
}
func AssignKey(keyID, serverID string) (*models.Assignment, error) {
func AssignKey(instanceID, keyID, serverID string) (*models.Assignment, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Check if already assigned and active
if _, err := GetKey(instanceID, keyID); err != nil {
return nil, fmt.Errorf("key not found")
}
if _, err := GetServer(instanceID, serverID); err != nil {
return nil, fmt.Errorf("server not found")
}
var existing models.Assignment
err := db.Col("assignments").FindOne(ctx, bson.M{
"key_id": keyID,
"server_id": serverID,
"revoked_at": nil,
"instance_id": instanceID,
"key_id": keyID,
"server_id": serverID,
"revoked_at": nil,
}).Decode(&existing)
if err == nil {
return &existing, nil
}
a := &models.Assignment{
InstanceID: instanceID,
KeyID: keyID,
ServerID: serverID,
AssignedAt: time.Now(),
@@ -171,23 +203,23 @@ func AssignKey(keyID, serverID string) (*models.Assignment, error) {
return a, nil
}
func RevokeAssignment(keyID, serverID string) error {
func RevokeAssignment(instanceID, keyID, serverID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
_, err := db.Col("assignments").UpdateOne(ctx,
bson.M{"key_id": keyID, "server_id": serverID, "revoked_at": nil},
bson.M{"instance_id": instanceID, "key_id": keyID, "server_id": serverID, "revoked_at": nil},
bson.M{"$set": bson.M{"revoked_at": now}},
)
return err
}
func GetAssignmentsForKey(keyID string) ([]models.Assignment, error) {
func GetAssignmentsForKey(instanceID, keyID string) ([]models.Assignment, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("assignments").Find(ctx, bson.M{"key_id": keyID, "revoked_at": nil})
cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "key_id": keyID, "revoked_at": nil})
if err != nil {
return nil, err
}
@@ -205,11 +237,11 @@ type AssignmentWithServer struct {
Server *models.Server `json:"server,omitempty"`
}
func GetAssignmentsWithServers(keyID string) ([]AssignmentWithServer, error) {
func GetAssignmentsWithServers(instanceID, keyID string) ([]AssignmentWithServer, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("assignments").Find(ctx, bson.M{"key_id": keyID})
cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "key_id": keyID})
if err != nil {
return nil, err
}
@@ -224,7 +256,7 @@ func GetAssignmentsWithServers(keyID string) ([]AssignmentWithServer, error) {
for _, a := range assignments {
item := AssignmentWithServer{Assignment: a}
var srv models.Server
if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID}).Decode(&srv); err == nil {
if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID, "instance_id": instanceID}).Decode(&srv); err == nil {
item.Server = &srv
}
result = append(result, item)
@@ -237,11 +269,11 @@ type AssignmentWithKey struct {
Key *models.Key `json:"key,omitempty"`
}
func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, error) {
func GetAssignmentsWithKeysForServer(instanceID, serverID string) ([]AssignmentWithKey, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("assignments").Find(ctx, bson.M{"server_id": serverID})
cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "server_id": serverID})
if err != nil {
return nil, err
}
@@ -255,7 +287,7 @@ func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, erro
result := make([]AssignmentWithKey, 0, len(assignments))
for _, a := range assignments {
var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key); err != nil {
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "instance_id": instanceID}).Decode(&key); err != nil {
continue
}
setKeyMeta(&key)
+175
View File
@@ -0,0 +1,175 @@
package services
import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
// LicenseState is the resolved licence for one instance.
type LicenseState struct {
Status license.State `json:"state"`
Reason string `json:"reason,omitempty"`
Tier string `json:"tier,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Limits license.Limits `json:"limits"`
Features map[string]bool `json:"features"`
// Source is "stored", "env" or "none" — useful when a self-hosted operator
// asks why the licence they pasted is not the one in effect.
Source string `json:"source"`
}
// Active reports whether mutations are allowed.
func (s LicenseState) Active() bool { return s.Status == license.StateValid }
// Feature reports whether a named feature is granted.
func (s LicenseState) Feature(name string) bool { return s.Features[name] }
// DeploymentMode is how this install describes itself to the verifier.
//
// It defaults to self_hosted, the stricter mode. An operator who removes the
// variable gets the tighter behaviour, not the looser one.
func DeploymentMode() string {
if strings.ToLower(os.Getenv("VANTAGE_DEPLOYMENT")) == license.DeploymentCloud {
return license.DeploymentCloud
}
return license.DeploymentSelfHosted
}
type cachedLicense struct {
state LicenseState
at time.Time
}
var (
licenseCacheMu sync.Mutex
licenseCache = map[string]cachedLicense{}
)
const licenseCacheTTL = 60 * time.Second
// InvalidateLicenseCache drops the cached state for one instance, so a pasted
// licence takes effect immediately rather than within the TTL.
func InvalidateLicenseCache(instanceID string) {
licenseCacheMu.Lock()
delete(licenseCache, instanceID)
licenseCacheMu.Unlock()
}
// GetLicenseState resolves the licence for an instance, cached for 60 seconds.
//
// Resolution order:
//
// 1. the blob stored on the instance document
// 2. VANTAGE_LICENSE, used ONLY when the instance has no stored blob, so an
// automated self-hosted deployment can ship a licence without a human
// pasting one
// 3. neither -> invalid / no_license
//
// A blob stored through the UI always wins afterwards, so an operator is never
// locked out by a stale environment value.
func GetLicenseState(instanceID string) LicenseState {
licenseCacheMu.Lock()
if e, ok := licenseCache[instanceID]; ok && time.Since(e.at) < licenseCacheTTL {
licenseCacheMu.Unlock()
return e.state
}
licenseCacheMu.Unlock()
state := resolveLicenseState(instanceID)
licenseCacheMu.Lock()
licenseCache[instanceID] = cachedLicense{state: state, at: time.Now()}
licenseCacheMu.Unlock()
return state
}
func resolveLicenseState(instanceID string) LicenseState {
inst, err := GetInstance(instanceID)
if err != nil {
return LicenseState{
Status: license.StateInvalid,
Reason: license.ReasonNoLicense,
Features: map[string]bool{},
Source: "none",
}
}
blob, source := inst.LicenseBlob, "stored"
if blob == "" {
blob, source = os.Getenv("VANTAGE_LICENSE"), "env"
}
if blob == "" {
return LicenseState{
Status: license.StateInvalid,
Reason: license.ReasonNoLicense,
Features: map[string]bool{},
Source: "none",
}
}
res := license.Verify(blob, license.VerifyOpts{
InstanceID: instanceID,
Deployment: DeploymentMode(),
})
return stateFromResult(res, source)
}
func stateFromResult(res license.Result, source string) LicenseState {
feats := map[string]bool{}
for _, f := range res.License.Features {
feats[f] = true
}
s := LicenseState{
Status: res.State,
Reason: res.Reason,
Tier: res.License.Tier,
Limits: res.License.Limits,
Features: feats,
Source: source,
}
if !res.License.ExpiresAt.IsZero() {
exp := res.License.ExpiresAt
s.ExpiresAt = &exp
}
return s
}
// StoreLicense verifies a blob against this instance and stores it.
//
// An expired-but-otherwise-valid blob IS stored, so the UI can show what expired
// and when. An invalid blob is rejected and the previous one kept.
func StoreLicense(instanceID, blob string) (LicenseState, error) {
blob = strings.TrimSpace(blob)
res := license.Verify(blob, license.VerifyOpts{
InstanceID: instanceID,
Deployment: DeploymentMode(),
})
if res.State == license.StateInvalid {
return LicenseState{}, fmt.Errorf("%s", res.Reason)
}
set := bson.M{
"license_blob": blob,
"license_tier": res.License.Tier,
"license_expiry": res.License.ExpiresAt,
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := db.Col("instances").UpdateOne(ctx,
bson.M{"instance_id": instanceID}, bson.M{"$set": set}); err != nil {
return LicenseState{}, err
}
InvalidateLicenseCache(instanceID)
return stateFromResult(res, "stored"), nil
}
+111
View File
@@ -0,0 +1,111 @@
package services
import (
"context"
"fmt"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
// LimitError is returned when a licence cap would be exceeded. The API maps it
// to 403 with a machine-readable body.
type LimitError struct {
Limit string
Current int
Max int
}
func (e *LimitError) Error() string {
return fmt.Sprintf("licence limit reached: %s (%d of %d)", e.Limit, e.Current, e.Max)
}
func limitCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
// CheckServerLimit refuses a new server when the instance is at its cap.
//
// Counts live rows only. An instance already over its cap keeps every server it
// has — nothing is truncated — it simply cannot add another.
func CheckServerLimit(instanceID string) error {
st := GetLicenseState(instanceID)
ctx, cancel := limitCtx()
defer cancel()
n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID})
if err != nil {
return err
}
if !license.WithinLimit(int(n), st.Limits.MaxServers) {
return &LimitError{Limit: "max_servers", Current: int(n), Max: st.Limits.MaxServers}
}
return nil
}
// CheckSecretGroupLimit refuses a NEW group at the cap. Writing to a group that
// already exists is always allowed, so a capped customer can still rotate the
// secrets they have.
func CheckSecretGroupLimit(instanceID, group string) error {
st := GetLicenseState(instanceID)
ctx, cancel := limitCtx()
defer cancel()
existing, err := db.Col("secrets").CountDocuments(ctx,
bson.M{"instance_id": instanceID, "group": group})
if err != nil {
return err
}
if existing > 0 {
return nil
}
var groups []string
if err := db.Col("secrets").Distinct(ctx, "group",
bson.M{"instance_id": instanceID}).Decode(&groups); err != nil {
return err
}
if !license.WithinLimit(len(groups), st.Limits.MaxSecretGroups) {
return &LimitError{Limit: "max_secret_groups", Current: len(groups), Max: st.Limits.MaxSecretGroups}
}
return nil
}
// CheckChannelLimit refuses a new notification channel at the cap.
func CheckChannelLimit(instanceID string) error {
st := GetLicenseState(instanceID)
ctx, cancel := limitCtx()
defer cancel()
n, err := db.Col("notification_channels").CountDocuments(ctx, bson.M{"instance_id": instanceID})
if err != nil {
return err
}
if !license.WithinLimit(int(n), st.Limits.MaxChannels) {
return &LimitError{Limit: "max_channels", Current: int(n), Max: st.Limits.MaxChannels}
}
return nil
}
// LicenseUsage reports current counts, so the UI can say "12 of 3 servers"
// honestly when an instance is over its cap rather than pretending.
func LicenseUsage(instanceID string) (servers, secretGroups, channels int) {
ctx, cancel := limitCtx()
defer cancel()
if n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil {
servers = int(n)
}
var groups []string
if err := db.Col("secrets").Distinct(ctx, "group",
bson.M{"instance_id": instanceID}).Decode(&groups); err == nil {
secretGroups = len(groups)
}
if n, err := db.Col("notification_channels").CountDocuments(ctx,
bson.M{"instance_id": instanceID}); err == nil {
channels = int(n)
}
return
}
+210
View File
@@ -0,0 +1,210 @@
package services
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// legacyOrg is the pre-0004 shape of the orgs collection.
//
// Migrations 0001 to 0003 run BEFORE the org-to-instance rename and must keep
// reading and writing org_id in the orgs collection. They deliberately do not
// use shared/models, which has moved on to Instance and instance_id.
type legacyOrg struct {
OrgID string `bson:"org_id"`
Name string `bson:"name"`
Slug string `bson:"slug"`
CreatedAt time.Time `bson:"created_at"`
}
var backfillCollections = []string{
"servers", "keys", "assignments", "secrets",
"workflows", "workflow_steps", "workflow_runs",
"audit_logs", "monitors", "notification_channels",
"console_sessions", "incidents", "monitor_rollups",
}
func defaultBackfillOrg(ctx context.Context) (*legacyOrg, error) {
var org legacyOrg
err := db.Col("orgs").FindOne(ctx, bson.M{"slug": "default"}).Decode(&org)
switch {
case err == nil:
case errors.Is(err, mongo.ErrNoDocuments):
org = legacyOrg{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()}
if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil {
return nil, err
}
default:
return nil, err
}
return &org, nil
}
func RunMigrations() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
const marker = "0001_default_org_backfill"
if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 {
return nil
}
needs := false
for _, col := range backfillCollections {
n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}})
if n > 0 {
needs = true
break
}
}
if needs {
org, err := defaultBackfillOrg(ctx)
if err != nil {
return err
}
for _, col := range backfillCollections {
if _, err := db.Col(col).UpdateMany(ctx,
bson.M{"org_id": bson.M{"$exists": false}},
bson.M{"$set": bson.M{"org_id": org.OrgID}},
); err != nil {
return err
}
}
}
_, err := db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()})
return err
}
func MigrateMissedOrgScopes() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
const marker = "0003_missed_org_scopes"
if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 {
return nil
}
missed := []string{"audit_logs", "notification_channels"}
needs := false
for _, col := range missed {
n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}})
if n > 0 {
needs = true
break
}
}
if needs {
org, err := defaultBackfillOrg(ctx)
if err != nil {
return err
}
for _, col := range missed {
if _, err := db.Col(col).UpdateMany(ctx,
bson.M{"org_id": bson.M{"$exists": false}},
bson.M{"$set": bson.M{"org_id": org.OrgID}},
); err != nil {
return err
}
}
}
if err := backfillOrgFromOwner(ctx, "console_sessions", "server_id", "servers", "server_id"); err != nil {
return err
}
if err := backfillOrgFromOwner(ctx, "incidents", "monitor_id", "monitors", "monitor_id"); err != nil {
return err
}
if err := backfillOrgFromOwner(ctx, "monitor_rollups", "monitor_id", "monitors", "monitor_id"); err != nil {
return err
}
_, err := db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()})
return err
}
func backfillOrgFromOwner(ctx context.Context, col, localField, ownerCol, ownerField string) error {
var raw []bson.RawValue
if err := db.Col(col).Distinct(ctx, localField,
bson.M{"org_id": bson.M{"$exists": false}}).Decode(&raw); err != nil {
return err
}
for _, rv := range raw {
id, ok := rv.StringValueOK()
if !ok || id == "" {
continue
}
var owner struct {
OrgID string `bson:"org_id"`
}
if err := db.Col(ownerCol).FindOne(ctx, bson.M{ownerField: id}).Decode(&owner); err != nil {
continue
}
if owner.OrgID == "" {
continue
}
if _, err := db.Col(col).UpdateMany(ctx,
bson.M{localField: id, "org_id": bson.M{"$exists": false}},
bson.M{"$set": bson.M{"org_id": owner.OrgID}},
); err != nil {
return err
}
}
return nil
}
func MigrateSettingsOrg() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
const marker = "0002_settings_org_backfill"
if n, _ := db.Col("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 {
return nil
}
n, _ := db.Col("settings").CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}})
if n > 0 {
var org legacyOrg
orgCount, err := db.Col("orgs").CountDocuments(ctx, bson.M{})
if err != nil {
return err
}
switch orgCount {
case 1:
if err := db.Col("orgs").FindOne(ctx, bson.M{}).Decode(&org); err != nil {
return err
}
case 0:
org = legacyOrg{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()}
if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil {
return err
}
default:
return fmt.Errorf(
"settings org migration: %d settings document(s) have no org_id but %d orgs exist; "+
"cannot infer the owner. Set org_id manually on each settings document "+
"(db.settings.updateOne({_id:<id>},{$set:{org_id:\"<org uuid>\"}})), deleting any "+
"duplicates, then restart", n, orgCount)
}
if org.OrgID != "" {
if _, err := db.Col("settings").UpdateMany(ctx,
bson.M{"org_id": bson.M{"$exists": false}},
bson.M{"$set": bson.M{"org_id": org.OrgID}},
); err != nil {
return err
}
}
}
_, err := db.Col("migrations").InsertOne(ctx, bson.M{"_id": marker, "applied_at": time.Now()})
return err
}
@@ -0,0 +1,239 @@
package services
import (
"context"
"fmt"
"log"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// ScopedCollections lists every collection carrying the tenant key.
//
// Migration 0004 renames org_id to instance_id in each. A collection missing
// from this list keeps the old field name and becomes invisible to every scoped
// query — so this list is load-bearing, not documentation.
//
// AssertNoScopedCollectionMissed checks at boot that nothing outside this list
// holds an org_id.
//
// The migrations collection is deliberately absent: it is not tenant-scoped.
// The two renamed collections appear under their post-rename names, because the
// migration renames the collections before it renames the field.
var ScopedCollections = []string{
"instances",
"servers",
"keys",
"assignments",
"users",
"instance_oidc",
"settings",
"secrets",
"workflows",
"workflow_steps",
"workflow_runs",
"monitors",
"incidents",
"monitor_rollups",
"notification_channels",
"console_sessions",
"audit_logs",
}
// collectionRenames maps the two collections whose names change. Ordered so the
// migration is deterministic.
var collectionRenames = []struct{ from, to string }{
{"orgs", "instances"},
{"org_oidc", "instance_oidc"},
}
// MigrateOrgToInstance renames the tenant key from org_id to instance_id.
//
// It only ever renames documents. It never deletes, drops or unsets one, so a
// bad deploy is recovered by running the inverse rename (cmd/rename-rollback)
// rather than by restoring a backup.
//
// The steps are not atomic across collections — multi-document transactions
// would require a replica set, which self-hosted installs do not guarantee.
// Instead every step is safely repeatable: a collection rename is skipped when
// the source is already gone, and $rename matches nothing on a document that
// has already been renamed. A run that fails partway is fixed by running it
// again.
//
// It must run BEFORE EnsureAuthIndexes. Creating the instances.slug index first
// would create an empty instances collection, and step 1 below refuses to
// rename orgs onto an existing target.
func MigrateOrgToInstance(ctx context.Context, db *mongo.Database) error {
names, err := db.ListCollectionNames(ctx, bson.M{})
if err != nil {
return fmt.Errorf("list collections: %w", err)
}
exists := map[string]bool{}
for _, n := range names {
exists[n] = true
}
// Step 1: rename the collections.
for _, r := range collectionRenames {
switch {
case !exists[r.from]:
// Nothing to rename: either already done or never existed.
continue
case exists[r.to]:
return fmt.Errorf("cannot rename %s to %s: both exist; resolve by hand", r.from, r.to)
}
cmd := bson.D{
{Key: "renameCollection", Value: db.Name() + "." + r.from},
{Key: "to", Value: db.Name() + "." + r.to},
}
if err := db.Client().Database("admin").RunCommand(ctx, cmd).Err(); err != nil {
return fmt.Errorf("rename %s to %s: %w", r.from, r.to, err)
}
log.Printf("0004: renamed collection %s to %s", r.from, r.to)
}
// Step 2: drop indexes keyed on the old field name, BEFORE renaming it.
//
// Order matters and is not obvious. A unique index on org_id treats a
// missing org_id as null, so as soon as $rename strips the field from the
// second document the index reports a duplicate null and the whole update
// fails. Dropping first avoids that entirely.
//
// Dropping an index touches no documents. The boot-time index builders
// recreate the current ones against the new field name.
for _, c := range ScopedCollections {
if err := DropIndexesKeyedOn(ctx, db, c, "org_id"); err != nil {
return err
}
}
// Step 3: rename the field.
for _, c := range ScopedCollections {
res, err := db.Collection(c).UpdateMany(ctx,
bson.M{"org_id": bson.M{"$exists": true}},
bson.M{"$rename": bson.M{"org_id": "instance_id"}},
)
if err != nil {
return fmt.Errorf("rename org_id in %s: %w", c, err)
}
if res.ModifiedCount > 0 {
log.Printf("0004: %s renamed %d document(s)", c, res.ModifiedCount)
}
}
// Step 4: verify before anyone records a marker. Any mismatch aborts, and
// the migration is re-run rather than marked done.
for _, c := range ScopedCollections {
total, err := db.Collection(c).CountDocuments(ctx, bson.M{})
if err != nil {
return fmt.Errorf("count %s: %w", c, err)
}
if total == 0 {
continue
}
stale, err := db.Collection(c).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}})
if err != nil {
return fmt.Errorf("count stale in %s: %w", c, err)
}
if stale != 0 {
return fmt.Errorf("%s still has %d document(s) with org_id; migration incomplete", c, stale)
}
scoped, err := db.Collection(c).CountDocuments(ctx, bson.M{"instance_id": bson.M{"$exists": true}})
if err != nil {
return fmt.Errorf("count scoped in %s: %w", c, err)
}
if scoped != total {
return fmt.Errorf("%s has %d document(s) but only %d carry instance_id", c, total, scoped)
}
}
log.Printf("0004: verified %d collection(s)", len(ScopedCollections))
return nil
}
// IndexKeyedOn reports whether an index specification's key document mentions
// field.
//
// The key is checked as both bson.D and bson.M because the driver's decoding of
// a nested document depends on the target type, and getting this wrong is
// silent: the index simply is not found, and the field rename then fails on a
// duplicate null.
func IndexKeyedOn(key any, field string) bool {
switch k := key.(type) {
case bson.M:
_, ok := k[field]
return ok
case bson.D:
for _, e := range k {
if e.Key == field {
return true
}
}
}
return false
}
// DropIndexesKeyedOn removes every index on coll whose key mentions field,
// leaving _id_ alone.
//
// Both the migration and its rollback must do this BEFORE renaming the field. A
// unique index treats a missing field as null, so as soon as $rename strips the
// field from the second document the index reports a duplicate null and the
// whole update fails. Dropping an index touches no documents; the boot-time
// index builders recreate what is needed.
func DropIndexesKeyedOn(ctx context.Context, db *mongo.Database, coll, field string) error {
cur, err := db.Collection(coll).Indexes().List(ctx)
if err != nil {
return fmt.Errorf("list indexes on %s: %w", coll, err)
}
var specs []bson.M
if err := cur.All(ctx, &specs); err != nil {
return fmt.Errorf("decode indexes on %s: %w", coll, err)
}
for _, s := range specs {
name, _ := s["name"].(string)
if name == "_id_" {
continue
}
if !IndexKeyedOn(s["key"], field) {
continue
}
if err := db.Collection(coll).Indexes().DropOne(ctx, name); err != nil {
return fmt.Errorf("drop index %s on %s: %w", name, coll, err)
}
log.Printf("dropped stale index %s on %s", name, coll)
}
return nil
}
// AssertNoScopedCollectionMissed reports any collection holding an org_id that
// ScopedCollections does not know about. A hit means a collection was added
// without being added to the list, and its tenant key was never renamed.
func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error {
known := map[string]bool{}
for _, c := range ScopedCollections {
known[c] = true
}
names, err := db.ListCollectionNames(ctx, bson.M{})
if err != nil {
return fmt.Errorf("list collections: %w", err)
}
for _, n := range names {
if known[n] {
continue
}
count, err := db.Collection(n).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}})
if err != nil {
return fmt.Errorf("count %s: %w", n, err)
}
if count > 0 {
return fmt.Errorf("collection %q holds %d document(s) with org_id but is not in ScopedCollections", n, count)
}
}
return nil
}

Some files were not shown because too many files have changed in this diff Show More