Compare commits

...
49 Commits
Author SHA1 Message Date
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
178 changed files with 17132 additions and 5763 deletions
+16
View File
@@ -35,3 +35,19 @@ jobs:
-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"
docker build -t "$IMAGE" -f sitesvc/Dockerfile sitesvc/
docker push "$IMAGE"
+2 -1
View File
@@ -8,4 +8,5 @@ installer/vantage-agent-windows-amd64.exe
installer/*.msi
installer/nssm.zip
installer/checksums-msi.txt
.next
.next
*.tsbuildinfo
+14 -14
View File
@@ -1,7 +1,3 @@
// Package checker runs service checks (http/tcp/icmp/tls) and returns a uniform
// Result. It has no dependency on models or pb so it can be duplicated verbatim
// into the agent module (agent-run monitors) — callers map their own monitor
// representation onto Spec.
package checker
import (
@@ -16,7 +12,7 @@ import (
"time"
)
// Check types (mirror models.Monitor* constants).
const (
TypeHTTP = "http"
TypeTCP = "tcp"
@@ -24,7 +20,7 @@ const (
TypeTLS = "tls"
)
// Spec is a self-contained description of a single check.
type Spec struct {
Type string
URL string
@@ -34,10 +30,11 @@ type Spec struct {
ExpectedStatus int
Keyword string
TLSWarnDays int
Insecure bool
TimeoutSec int
}
// Result is the uniform outcome of running a check.
type Result struct {
Up bool
LatencyMs int
@@ -53,7 +50,7 @@ func (s Spec) timeout() time.Duration {
return time.Duration(t) * time.Second
}
// Run executes the check described by s.
func Run(ctx context.Context, s Spec) Result {
switch s.Type {
case TypeHTTP:
@@ -79,6 +76,9 @@ func runHTTP(ctx context.Context, s Spec) Result {
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 {
@@ -156,9 +156,9 @@ func runTLS(ctx context.Context, s Spec) Result {
func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) }
// runICMP sends a single ICMP echo request and waits for the reply. Requires
// raw-socket privileges (the agent and server run as root). Returns down with a
// descriptive message when the socket cannot be opened or no reply arrives.
func runICMP(ctx context.Context, s Spec) Result {
dst, err := net.ResolveIPAddr("ip4", s.Host)
if err != nil {
@@ -188,18 +188,18 @@ func runICMP(ctx context.Context, s Spec) Result {
if err != nil {
return Result{LatencyMs: msSince(start), Message: "no reply"}
}
// Skip the IPv4 header (20 bytes) to reach the ICMP message.
if n < 28 || peer.String() != dst.String() {
continue
}
if reply[20] == 0 { // ICMP echo reply type
if reply[20] == 0 {
return Result{Up: true, LatencyMs: msSince(start)}
}
}
}
func icmpEcho(id, seq int) []byte {
// Type(8)=echo request, Code=0, Checksum, ID, Seq, no payload.
b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)}
cs := icmpChecksum(b)
b[2] = byte(cs >> 8)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"gopkg.in/yaml.v3"
)
// ConfigDir returns the platform-specific config directory.
func ConfigDir() string {
if runtime.GOOS == "windows" {
base := os.Getenv("ProgramData")
+18 -18
View File
@@ -14,9 +14,9 @@ import (
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
)
// streamWriter forwards every write to emit() as an ordered chunk. Used as both
// Stdout and Stderr so output interleaves in real execution order. The mutex
// ensures a single stdout/stderr write is not interleaved mid-slice with another.
type streamWriter struct {
mu sync.Mutex
seq uint64
@@ -35,21 +35,21 @@ func (w *streamWriter) Write(p []byte) (int, error) {
return len(p), nil
}
// WorkspacePath returns the per-run working directory for a workspace id. The
// same id always maps to the same path so RunStep and the cleanup command agree.
func WorkspacePath(workspaceID string) string {
return filepath.Join(os.TempDir(), "vantage-run-"+workspaceID)
}
// RunStep writes the script to a temp file, provides a WORKFLOW_ENV file for
// the script to append KEY=value output to, executes it under the requested
// interpreter, and streams output via emit, returning the terminal result
// with empty stdout/stderr but populated exit_code/output_env.
//
// When the command carries a WorkspaceId the step runs with that per-run working
// directory as its cwd (created here if missing); the server removes it once the
// run finishes. The script and env files always live in a private temp dir so
// they never leak into the shared workspace.
func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepResult {
res := &pb.StepResult{CommandId: "", OutputEnv: map[string]string{}}
@@ -102,7 +102,7 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
}
}
c = exec.CommandContext(ctx, shell, "-NoProfile", "-NonInteractive", "-File", scriptPath)
default: // "bash"
default:
scriptPath = filepath.Join(dir, "step.sh")
if err := os.WriteFile(scriptPath, []byte(cmd.Script), 0700); err != nil {
res.ExitCode = 1
@@ -126,7 +126,7 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
c.Stderr = sw
runErr := c.Run()
// stdout/stderr are streamed via emit, not returned in the result.
if ctx.Err() == context.DeadlineExceeded {
res.ExitCode = 124
res.Stderr = "[vantage] step timed out"
@@ -141,8 +141,8 @@ func RunStep(cmd *pb.RunStepCmd, emit func(seq uint64, data []byte)) *pb.StepRes
return res
}
// parseEnvFile reads KEY=value lines (last write wins). Blank lines and lines
// without '=' are ignored.
func parseEnvFile(path string) map[string]string {
out := map[string]string{}
f, err := os.Open(path)
+4 -4
View File
@@ -27,8 +27,8 @@ func New(serverURL string, useTLS bool) (*Client, error) {
serverURL = strings.TrimPrefix(serverURL, "https://")
serverURL = strings.TrimPrefix(serverURL, "http://")
// Send a ping every 30s so proxies with a 60s idle timeout don't kill the
// long-lived CommandStream when no commands are flowing.
dialOpts := []grpc.DialOption{
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: 30 * time.Second,
@@ -150,8 +150,8 @@ func (c *Client) ReportChecks(serverID, agentToken string, results []pb.CheckRes
return err
}
// CommandStream opens a long-lived bidirectional stream for server-pushed commands.
// The caller controls the stream lifetime via ctx.
func (c *Client) CommandStream(ctx context.Context) (pb.Vantage_CommandStreamClient, error) {
return c.client.CommandStream(ctx)
}
+11 -10
View File
@@ -1,4 +1,4 @@
// Hand-written gRPC bindings for vantage.proto (agent side, JSON codec).
package pb
@@ -44,7 +44,7 @@ type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
// CommandStream message types
type PackageUpdate struct {
Name string `json:"name"`
@@ -60,7 +60,7 @@ type ReportUpdatesRequest struct {
type ReportUpdatesResponse struct{}
// Inventory report message types
type CPUReport struct {
Model string `json:"model,omitempty"`
@@ -92,7 +92,7 @@ type InventoryReport struct {
}
type InventoryReportResponse struct{}
// Monitor sync / check report message types
type MonitorSpec struct {
MonitorId string `json:"monitor_id"`
@@ -104,6 +104,7 @@ type MonitorSpec struct {
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"`
}
@@ -140,8 +141,8 @@ type ServerCommand struct {
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
}
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
// directory once all steps on that server have finished.
type CleanupWorkspaceCmd struct {
WorkspaceId string `json:"workspace_id"`
}
@@ -185,8 +186,8 @@ type RunStepCmd struct {
Script string `json:"script"`
Env map[string]string `json:"env,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
// WorkspaceId names the per-run working directory the agent creates and uses
// as the step's cwd. Empty means run in the agent's default directory.
WorkspaceId string `json:"workspace_id,omitempty"`
}
@@ -205,7 +206,7 @@ type StepOutputChunk struct {
Eof bool `json:"eof,omitempty"`
}
// CommandStream client-side interface
type Vantage_CommandStreamClient interface {
Send(*AgentMessage) error
@@ -229,7 +230,7 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
return m, nil
}
// CommandStream server-side interface (included for completeness)
type Vantage_CommandStreamServer interface {
Send(*ServerCommand) error
+3 -3
View File
@@ -1,4 +1,4 @@
//go:build linux
package inventory
@@ -42,11 +42,11 @@ func cpuSample() (idle, total uint64) {
defer f.Close()
sc := bufio.NewScanner(f)
if sc.Scan() {
fields := strings.Fields(sc.Text()) // cpu user nice system idle iowait ...
fields := strings.Fields(sc.Text())
for i, v := range fields[1:] {
n, _ := strconv.ParseUint(v, 10, 64)
total += n
if i == 3 { // idle
if i == 3 {
idle = n
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
//go:build !linux
package inventory
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
// collect is a no-op best-effort stub on non-Linux platforms.
func collect(r *pb.InventoryReport, includeStatic bool) {}
+2 -2
View File
@@ -2,8 +2,8 @@ package inventory
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
// Collect gathers metrics always and static hardware info when includeStatic.
// Platform specifics are provided by collect_linux.go / collect_other.go.
func Collect(includeStatic bool) *pb.InventoryReport {
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
collect(r, includeStatic)
+17 -17
View File
@@ -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
// vantage.conf include file, and ensures ~/.ssh/config includes it.
func AddSSHIdentity(keyPath string) error {
if err := os.MkdirAll(filepath.Dir(sshConfigPath), 0700); err != nil {
return fmt.Errorf("mkdir .ssh: %w", err)
@@ -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/vantage.conf" to the top
// of ~/.ssh/config if it is not already present. The Include must appear before
// any Host stanzas to be effective for all connections.
func ensureIncludeDirective() error {
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)
+9 -8
View File
@@ -1,6 +1,6 @@
// Package monitors runs agent-side service checks. It polls the server for the
// monitors assigned to this agent (SyncMonitors), runs each on its own interval
// using the local checker package, and reports results back (ReportChecks).
package monitors
import (
@@ -15,7 +15,7 @@ import (
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
)
// syncInterval controls how often the agent re-fetches its assigned monitors.
const syncInterval = 30 * time.Second
type runner struct {
@@ -23,13 +23,13 @@ type runner struct {
cancel context.CancelFunc
}
// Run starts the agent monitor loop and blocks until ctx is cancelled.
func Run(ctx context.Context, cfg *config.Config) {
active := map[string]*runner{}
var mu sync.Mutex
// results is a shared channel every check writes to; a single reporter
// goroutine batches and ships them so we make one ReportChecks call per tick.
results := make(chan pb.CheckResult, 64)
go reporter(ctx, cfg, results)
@@ -99,6 +99,7 @@ func runSpec(ctx context.Context, s pb.MonitorSpec, out chan<- pb.CheckResult) {
ExpectedStatus: s.ExpectedStatus,
Keyword: s.Keyword,
TLSWarnDays: s.TLSWarnDays,
Insecure: s.Insecure,
TimeoutSec: s.IntervalSec,
}
@@ -127,7 +128,7 @@ func runSpec(ctx context.Context, s pb.MonitorSpec, out chan<- pb.CheckResult) {
}
}
// reporter batches results on a short interval and ships each batch in one call.
func reporter(ctx context.Context, cfg *config.Config, in <-chan pb.CheckResult) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
+31 -32
View File
@@ -34,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()
@@ -61,25 +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)
// Check for OS updates on startup and then hourly.
go runUpdateCheck(ctx, cfg)
// Report host inventory: metrics every 30s, full static snapshot every 15 min.
go runInventory(ctx, cfg)
// Run agent-side service monitors assigned to this server.
go monitors.Run(ctx, cfg)
ticker := time.NewTicker(cfg.PollInterval)
defer ticker.Stop()
// Run immediately on startup
if err := poll(client, cfg, version); err != nil {
log.Printf("poll error: %v", err)
}
@@ -102,7 +102,6 @@ func poll(client *grpcclient.Client, cfg *config.Config, version string) error {
return fmt.Errorf("SyncKeys: %w", err)
}
// Windows agents register and heartbeat only — no authorized_keys management.
if runtime.GOOS != "linux" {
return nil
}
@@ -124,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
@@ -178,9 +177,9 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
log.Println("command stream connected")
// grpc streams are not safe for concurrent Send; RunStep results are sent
// from per-command goroutines, so all sends on this stream must go through
// this mutex-protected helper.
var sendMu sync.Mutex
send := func(msg *pb.AgentMessage) error {
sendMu.Lock()
@@ -220,7 +219,7 @@ func connectAndHandleStream(ctx context.Context, cfg *config.Config) error {
}
res := agentexec.RunStep(rc, emit)
res.CommandId = cid
// Final eof marker so the server closes the log file.
_ = send(&pb.AgentMessage{
ServerId: cfg.ServerID,
AgentToken: cfg.AgentToken,
@@ -280,8 +279,8 @@ func runUpdateCheck(ctx context.Context, cfg *config.Config) {
}
}
// runInventory reports host metrics every 30s and a full static snapshot every
// 15 min (and once immediately on startup so static fields populate without delay).
func runInventory(ctx context.Context, cfg *config.Config) {
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
@@ -299,7 +298,7 @@ func runInventory(ctx context.Context, cfg *config.Config) {
}
}
report(true) // full snapshot on startup
report(true)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
tick := 0
@@ -309,7 +308,7 @@ func runInventory(ctx context.Context, cfg *config.Config) {
return
case <-ticker.C:
tick++
report(tick%30 == 0) // every 30th tick = 15 min → include static
report(tick%30 == 0)
}
}
}
@@ -322,7 +321,7 @@ func handleApplyUpdates(cfg *config.Config, cmd *pb.ServerCommand) {
}
log.Printf("OS updates applied successfully (cmd=%s)", cmd.CommandId)
// Re-report the (now empty) update list so the server reflects the new state.
client, err := grpcclient.New(cfg.ServerURL, cfg.TLS)
if err != nil {
return
@@ -364,21 +363,21 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
}
u := cmd.UpdateAgent
arch := runtime.GOARCH // "amd64" or "arm64"
arch := runtime.GOARCH
tag := "agent%2Fv" + u.Version
binaryURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/vantage-agent-linux-%s", u.GiteaBaseURL, tag, arch)
checksumURL := fmt.Sprintf("%s/mrhid6/vantage/releases/download/%s/checksums.txt", u.GiteaBaseURL, tag)
log.Printf("updating agent to v%s from %s (cmd=%s)", u.Version, u.GiteaBaseURL, cmd.CommandId)
// Download binary
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)
@@ -403,11 +402,11 @@ func handleUpdateAgent(cmd *pb.ServerCommand) {
exec.Command("systemctl", "restart", "vantage-agent").Run()
}
// handleUpdateAgentWindows downloads the latest MSI and launches msiexec to
// perform a MajorUpgrade. msiexec is started DETACHED (via "cmd /c start") so
// that when the upgrade stops the VantageAgent service, nssm's process-tree
// kill of this agent does not also kill the installer mid-flight. Config
// (server_id, agent_token) is preserved by setup.ps1 on upgrade.
func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
u := cmd.UpdateAgent
tag := "agent%2Fv" + u.Version
@@ -435,8 +434,8 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
logPath := filepath.Join(os.TempDir(), "vantage-agent-msi.log")
log.Printf("launching msiexec for upgrade to v%s (cmd=%s)", u.Version, cmd.CommandId)
// "start" detaches msiexec from this process tree so the service stop
// during the upgrade does not terminate the installer.
up := exec.Command("cmd", "/c", "start", "", "/wait", "msiexec", "/i", msiPath, "/qn", "/norestart", "/l*v", logPath)
if err := up.Start(); err != nil {
log.Printf("failed to launch msiexec (cmd=%s): %v", cmd.CommandId, err)
@@ -445,7 +444,7 @@ func handleUpdateAgentWindows(cmd *pb.ServerCommand) {
}
func downloadFile(url, dest string) error {
resp, err := http.Get(url) //nolint:gosec
resp, err := http.Get(url)
if err != nil {
return err
}
@@ -463,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
}
@@ -555,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 {
+10 -11
View File
@@ -27,8 +27,8 @@ func detectPM() string {
return ""
}
// CheckAvailable returns the list of packages with available upgrades.
// Returns nil, nil when no supported package manager is found.
func CheckAvailable() ([]PackageUpdate, error) {
switch detectPM() {
case "apt":
@@ -48,11 +48,11 @@ func CheckAvailable() ([]PackageUpdate, error) {
}
}
// ApplyAll runs a full non-interactive upgrade using the detected package manager.
func ApplyAll() error {
switch detectPM() {
case "apt":
// Refresh lists first, then upgrade.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if err := exec.CommandContext(ctx, "apt-get", "update", "-qq").Run(); err != nil {
@@ -77,8 +77,8 @@ func ApplyAll() error {
func checkApt() ([]PackageUpdate, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
// Best-effort refresh; ignore errors (cached data is fine).
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run() //nolint:errcheck
exec.CommandContext(ctx, "apt-get", "update", "-qq").Run()
out, err := exec.Command("apt", "list", "--upgradable").Output()
if err != nil {
@@ -88,7 +88,7 @@ func checkApt() ([]PackageUpdate, error) {
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
// Format: package/suite version arch [upgradable from: old-ver]
if !strings.Contains(line, "[upgradable from:") {
continue
}
@@ -111,7 +111,6 @@ func checkApt() ([]PackageUpdate, error) {
func checkDnfYum(pm string) ([]PackageUpdate, error) {
cmd := exec.Command(pm, "check-update")
out, err := cmd.Output()
// Exit code 100 means updates are available — not an error.
if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 100 {
err = nil
}
@@ -133,7 +132,7 @@ func checkDnfYum(pm string) ([]PackageUpdate, error) {
if len(parts) < 2 {
continue
}
// name.arch new-version repo
name := strings.SplitN(parts[0], ".", 2)[0]
updates = append(updates, PackageUpdate{Name: name, NewVersion: parts[1]})
}
@@ -146,7 +145,7 @@ func checkPacman() ([]PackageUpdate, error) {
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
parts := strings.Fields(scanner.Text())
// Format: package old-version -> new-version
if len(parts) < 4 {
continue
}
@@ -164,7 +163,7 @@ func checkZypper() ([]PackageUpdate, error) {
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
// Data rows start with "v |" (available) or "i |" (installed but updatable).
if !strings.HasPrefix(line, "v |") && !strings.HasPrefix(line, "i |") {
continue
}
+342 -234
View File
@@ -1,34 +1,38 @@
# 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
@@ -36,143 +40,245 @@ A self-hosted SSH key management system. A central server (Go + Next.js + MongoD
```
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/
── vantage/v1/vantage.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 vantage.v1;
service Vantage {
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;
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/vantage/config.yaml`
### Config file
Linux `/etc/vantage/config.yaml`, Windows `%ProgramData%\vantage\config.yaml`. Directory `0700`, file `0600`.
```yaml
server_url: "vantage.yourdomain.com:9090"
@@ -183,117 +289,119 @@ 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/vantage-agent.service`
```ini
[Unit]
Description=Vantage Agent
After=network.target
[Service]
ExecStart=/usr/local/bin/vantage-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://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/vantage/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/vantage/config.yaml` (`0600`)
- Server stores `SHA-256(agent_token)` — never the plaintext token
- Private keys generated by agents are encrypted at rest in MongoDB (AES-256)
- `authorized_keys` written as `0600`, owned by root
- 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 \
@@ -301,51 +409,51 @@ GOOS=linux GOARCH=amd64 go build \
-o dist/vantage-agent-linux-amd64 ./cmd
```
Release assets:
### `server-deploy.yml` — triggered on every push to `main`
- `vantage-agent-linux-amd64`
- `vantage-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/vantage && 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.
+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}
-5
View File
@@ -27,14 +27,9 @@ services:
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
@@ -1,866 +0,0 @@
# Fleet Inventory Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Agents collect CPU/RAM/swap/disk/partition inventory and report it to the server via a new `ReportInventory` RPC; the server stores the latest snapshot per server and the UI displays it.
**Architecture:** New unary gRPC `ReportInventory` (mirrors existing `ReportUpdates`). Agent runs a 30s metrics ticker (CPU/RAM/swap usage) and, every 15 min, a full static collection (disks, partitions, CPU model, kernel). Server upserts an embedded `inventory` sub-doc on the `servers` document with merge rules that preserve static fields between slow ticks.
**Tech Stack:** Go (gin, mongo-driver v2, hand-written JSON-codec gRPC), `/proc` readers, Next.js 16 + react-query + Tailwind.
## Global Constraints
- **No tests this iteration.** Verify with `go build ./...`, `go vet ./...`, `npm run build`.
- gRPC uses a JSON codec: edit **both** `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go` identically, plus `proto/vantage/v1/vantage.proto` as documentation. No codegen. Mirror the existing `ReportUpdates` RPC wiring exactly (service interface, `_Vantage_*_Handler`, client method, `Vantage_ServiceDesc`).
- Mongo: `db.Col("servers")`, `context.WithTimeout`. Follow `server/internal/services/servers.go`.
- Agent already runs as root; `/proc` is readable. Linux is primary; Windows collectors may return empty.
- Module path `github.com/mrhid6/vantage`.
- Do not add heavy dependencies; implement `/proc` parsing directly.
---
## Task 1: Inventory model + gRPC messages
**Files:**
- Modify: `server/internal/models/server.go`
- Modify: `proto/vantage/v1/vantage.proto`
- Modify: `server/internal/grpc/pb/vantage.pb.go`
- Modify: `agent/internal/grpc/pb/vantage.pb.go`
**Interfaces:**
- Produces: `models.Inventory` (+ `CPUInfo`, `MemInfo`, `Partition`) and `Server.Inventory *Inventory`. pb structs `InventoryReport`, `CPUReport`, `MemReport`, `PartitionReport`, `InventoryReportResponse`. Service method `ReportInventory` on both client and server interfaces.
- [ ] **Step 1: Add model structs**
In `server/internal/models/server.go` add (keep the existing `import "time"`):
```go
type CPUInfo struct {
Model string `bson:"model,omitempty" json:"model,omitempty"`
Cores int `bson:"cores,omitempty" json:"cores,omitempty"`
UsagePct float64 `bson:"usage_pct" json:"usage_pct"`
Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"`
}
type MemInfo struct {
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
}
type Partition struct {
Device string `bson:"device" json:"device"`
Mountpoint string `bson:"mountpoint" json:"mountpoint"`
Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"`
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
}
type Inventory struct {
CPU CPUInfo `bson:"cpu" json:"cpu"`
Memory MemInfo `bson:"memory" json:"memory"`
SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"`
SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"`
Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"`
Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"`
MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
}
```
Add to the `Server` struct: `Inventory *Inventory \`bson:"inventory,omitempty" json:"inventory,omitempty"\``.
- [ ] **Step 2: Document RPC in proto**
In `proto/vantage/v1/vantage.proto`, add to the service: `rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);` and the messages `InventoryReport`, `CPUReport`, `MemReport`, `PartitionReport`, `InventoryReportResponse` per spec §4.
- [ ] **Step 3: Add pb structs + RPC wiring (server pb)**
In `server/internal/grpc/pb/vantage.pb.go` add the message structs:
```go
type CPUReport struct {
Model string `json:"model,omitempty"`
Cores int `json:"cores,omitempty"`
UsagePct float64 `json:"usage_pct"`
Load1 float64 `json:"load1,omitempty"`
}
type MemReport struct {
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
}
type PartitionReport struct {
Device string `json:"device"`
Mountpoint string `json:"mountpoint"`
Fstype string `json:"fstype,omitempty"`
TotalBytes uint64 `json:"total_bytes"`
UsedBytes uint64 `json:"used_bytes"`
}
type InventoryReport struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
IncludeStatic bool `json:"include_static"`
CPU *CPUReport `json:"cpu,omitempty"`
Memory *MemReport `json:"memory,omitempty"`
SwapTotal uint64 `json:"swap_total"`
SwapUsed uint64 `json:"swap_used"`
Partitions []PartitionReport `json:"partitions,omitempty"`
Kernel string `json:"kernel,omitempty"`
}
type InventoryReportResponse struct{}
```
Then mirror the `ReportUpdates` RPC plumbing for `ReportInventory`. Locate every `ReportUpdates` reference in this file and add the parallel `ReportInventory`:
- `VantageServer` interface: add `ReportInventory(context.Context, *InventoryReport) (*InventoryReportResponse, error)`.
- `UnimplementedVantageServer`: add the stub returning `Unimplemented`.
- `VantageClient` interface + `keyManagerClient`: add the client method `Invoke`-ing `/vantage.v1.Vantage/ReportInventory`.
- `Vantage_ServiceDesc.Methods`: add `{MethodName: "ReportInventory", Handler: _Vantage_ReportInventory_Handler}`.
- Add `_Vantage_ReportInventory_Handler` copied from `_Vantage_ReportUpdates_Handler` with types swapped.
- [ ] **Step 4: Mirror pb structs + wiring (agent pb)**
Apply the identical additions to `agent/internal/grpc/pb/vantage.pb.go`.
- [ ] **Step 5: Verify build**
Run: `cd server && go build ./... && cd ../agent && go build ./...`
Expected: both succeed.
- [ ] **Step 6: Commit**
```bash
git add server/internal/models/server.go proto/vantage/v1/vantage.proto server/internal/grpc/pb/vantage.pb.go agent/internal/grpc/pb/vantage.pb.go
git commit -m "feat(proto): add ReportInventory RPC and inventory model"
```
---
## Task 2: Server handler + store service
**Files:**
- Create: `server/internal/services/inventory.go`
- Modify: `server/internal/grpc/server.go`
**Interfaces:**
- Consumes: `pb.InventoryReport` (T1), `db.Col("servers")`.
- Produces: `services.StoreInventory(serverID string, r *pb.InventoryReport) error`; gRPC method `(*vantageServer).ReportInventory`.
- [ ] **Step 1: Write the store service**
```go
package services
import (
"context"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/grpc/pb"
"go.mongodb.org/mongo-driver/v2/bson"
)
// StoreInventory upserts the latest inventory snapshot onto the server document.
// Metrics fields update every call; static fields only when r.IncludeStatic.
func StoreInventory(serverID string, r *pb.InventoryReport) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
set := bson.M{"inventory.metrics_at": now}
if r.CPU != nil {
set["inventory.cpu.usage_pct"] = r.CPU.UsagePct
set["inventory.cpu.load1"] = r.CPU.Load1
}
if r.Memory != nil {
set["inventory.memory.used_bytes"] = r.Memory.UsedBytes
}
set["inventory.swap_used_bytes"] = r.SwapUsed
if r.IncludeStatic {
set["inventory.static_at"] = now
set["inventory.swap_total_bytes"] = r.SwapTotal
set["inventory.kernel"] = r.Kernel
if r.CPU != nil {
set["inventory.cpu.model"] = r.CPU.Model
set["inventory.cpu.cores"] = r.CPU.Cores
}
if r.Memory != nil {
set["inventory.memory.total_bytes"] = r.Memory.TotalBytes
}
parts := make([]bson.M, 0, len(r.Partitions))
for _, p := range r.Partitions {
parts = append(parts, bson.M{
"device": p.Device, "mountpoint": p.Mountpoint, "fstype": p.Fstype,
"total_bytes": p.TotalBytes, "used_bytes": p.UsedBytes,
})
}
set["inventory.partitions"] = parts
}
_, err := db.Col("servers").UpdateOne(ctx, bson.M{"server_id": serverID}, bson.M{"$set": set})
return err
}
```
- [ ] **Step 2: Add the gRPC handler**
In `server/internal/grpc/server.go`, add (mirroring the existing `ReportUpdates` handler that validates the agent token):
```go
func (s *vantageServer) ReportInventory(ctx context.Context, req *pb.InventoryReport) (*pb.InventoryReportResponse, error) {
srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
if err := services.StoreInventory(srv.ServerID, req); err != nil {
log.Printf("store inventory for %s: %v", srv.ServerID, err)
}
return &pb.InventoryReportResponse{}, nil
}
```
Confirm `status`, `codes`, `log` are already imported in the file (they are, used by other handlers).
- [ ] **Step 3: Verify build**
Run: `cd server && go build ./... && go vet ./...`
Expected: success.
- [ ] **Step 4: Commit**
```bash
git add server/internal/services/inventory.go server/internal/grpc/server.go
git commit -m "feat(server): store inventory and handle ReportInventory RPC"
```
---
## Task 3: Agent collectors
**Files:**
- Create: `agent/internal/inventory/collect_linux.go`
- Create: `agent/internal/inventory/collect_other.go`
- Create: `agent/internal/inventory/inventory.go`
**Interfaces:**
- Produces: `inventory.Collect(includeStatic bool) *pb.InventoryReport`.
- [ ] **Step 1: Common entry (`inventory.go`)**
```go
package inventory
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
// Collect gathers metrics always and static hardware info when includeStatic.
// Platform specifics are provided by collect_linux.go / collect_other.go.
func Collect(includeStatic bool) *pb.InventoryReport {
r := &pb.InventoryReport{IncludeStatic: includeStatic, CPU: &pb.CPUReport{}, Memory: &pb.MemReport{}}
collect(r, includeStatic)
return r
}
```
- [ ] **Step 2: Linux collector (`collect_linux.go`)**
Build-tagged `//go:build linux`. Implement `collect(r *pb.InventoryReport, includeStatic bool)`:
- CPU usage: read `/proc/stat` first line twice ~100ms apart, compute `1 - idleDelta/totalDelta` × 100 → `r.CPU.UsagePct`.
- Load: first field of `/proc/loadavg` → `r.CPU.Load1`.
- Mem/swap: parse `/proc/meminfo` (`MemTotal`, `MemAvailable`, `SwapTotal`, `SwapFree`; used = total available; swap used = swaptotal swapfree) → `r.Memory.*`, `r.SwapUsed`, and on static `r.SwapTotal`.
- Static only: `/proc/cpuinfo` (`model name`, count `processor` lines) → `r.CPU.Model/Cores`; `/proc/meminfo MemTotal` → `r.Memory.TotalBytes`; kernel via `syscall.Uname` or read `/proc/sys/kernel/osrelease` → `r.Kernel`; partitions from `/proc/mounts` filtered to fstypes in {ext4,xfs,btrfs,zfs,vfat,ntfs} then `syscall.Statfs` for total/used → `r.Partitions`.
```go
//go:build linux
package inventory
import (
"bufio"
"os"
"strconv"
"strings"
"syscall"
"time"
"github.com/mrhid6/vantage/agent/internal/grpc/pb"
)
func collect(r *pb.InventoryReport, includeStatic bool) {
r.CPU.UsagePct = cpuUsage()
r.CPU.Load1 = load1()
memTotal, memAvail, swapTotal, swapFree := meminfo()
if memTotal > memAvail {
r.Memory.UsedBytes = memTotal - memAvail
}
if swapTotal > swapFree {
r.SwapUsed = swapTotal - swapFree
}
if includeStatic {
r.Memory.TotalBytes = memTotal
r.SwapTotal = swapTotal
r.CPU.Model, r.CPU.Cores = cpuStatic()
r.Kernel = kernel()
r.Partitions = partitions()
}
}
func readProc(path string) string { b, _ := os.ReadFile(path); return string(b) }
func cpuSample() (idle, total uint64) {
f, err := os.Open("/proc/stat")
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
if sc.Scan() {
fields := strings.Fields(sc.Text()) // cpu user nice system idle iowait ...
for i, v := range fields[1:] {
n, _ := strconv.ParseUint(v, 10, 64)
total += n
if i == 3 { // idle
idle = n
}
}
}
return
}
func cpuUsage() float64 {
i1, t1 := cpuSample()
time.Sleep(100 * time.Millisecond)
i2, t2 := cpuSample()
dt := float64(t2 - t1)
if dt <= 0 {
return 0
}
return (1 - float64(i2-i1)/dt) * 100
}
func load1() float64 {
fields := strings.Fields(readProc("/proc/loadavg"))
if len(fields) > 0 {
v, _ := strconv.ParseFloat(fields[0], 64)
return v
}
return 0
}
func meminfo() (total, avail, swapTotal, swapFree uint64) {
f, err := os.Open("/proc/meminfo")
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 2 {
continue
}
kb, _ := strconv.ParseUint(fields[1], 10, 64)
b := kb * 1024
switch strings.TrimSuffix(fields[0], ":") {
case "MemTotal":
total = b
case "MemAvailable":
avail = b
case "SwapTotal":
swapTotal = b
case "SwapFree":
swapFree = b
}
}
return
}
func cpuStatic() (model string, cores int) {
f, err := os.Open("/proc/cpuinfo")
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if strings.HasPrefix(line, "processor") {
cores++
} else if strings.HasPrefix(line, "model name") && model == "" {
if i := strings.Index(line, ":"); i >= 0 {
model = strings.TrimSpace(line[i+1:])
}
}
}
return
}
func kernel() string {
return strings.TrimSpace(readProc("/proc/sys/kernel/osrelease"))
}
func partitions() []pb.PartitionReport {
allowed := map[string]bool{"ext4": true, "xfs": true, "btrfs": true, "zfs": true, "vfat": true, "ntfs": true, "ext3": true}
f, err := os.Open("/proc/mounts")
if err != nil {
return nil
}
defer f.Close()
var out []pb.PartitionReport
seen := map[string]bool{}
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 3 || !allowed[fields[2]] || seen[fields[1]] {
continue
}
seen[fields[1]] = true
var st syscall.Statfs_t
if syscall.Statfs(fields[1], &st) != nil {
continue
}
total := st.Blocks * uint64(st.Bsize)
free := st.Bavail * uint64(st.Bsize)
out = append(out, pb.PartitionReport{
Device: fields[0], Mountpoint: fields[1], Fstype: fields[2],
TotalBytes: total, UsedBytes: total - free,
})
}
return out
}
```
- [ ] **Step 3: Non-linux stub (`collect_other.go`)**
```go
//go:build !linux
package inventory
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
// collect is a no-op best-effort stub on non-Linux platforms.
func collect(r *pb.InventoryReport, includeStatic bool) {}
```
- [ ] **Step 4: Verify build**
Run: `cd agent && go build ./... && go vet ./...`
Expected: success (build both native and, if convenient, `GOOS=windows go build ./...`).
- [ ] **Step 5: Commit**
```bash
git add agent/internal/inventory/
git commit -m "feat(agent): /proc-based inventory collectors"
```
---
## Task 4: Agent client method + scheduler
**Files:**
- Modify: `agent/internal/grpc/client.go`
- Modify: the agent main loop (`agent/cmd/main.go` or `agent/internal/sync/sync.go` — wherever the poll loop/tickers live).
**Interfaces:**
- Consumes: `inventory.Collect` (T3), pb (T1).
- Produces: `(*Client).ReportInventory(report *pb.InventoryReport) error`; a running ticker that reports metrics every 30s and static every 15 min.
- [ ] **Step 1: Add client method**
In `agent/internal/grpc/client.go`, mirroring `ReportUpdates`:
```go
func (c *Client) ReportInventory(report *pb.InventoryReport) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := c.client.ReportInventory(ctx, report)
return err
}
```
The report already carries `ServerId`/`AgentToken`; ensure the caller sets them (see Step 2).
- [ ] **Step 2: Add the scheduler to the agent loop**
Find where the agent starts its poll loop (the goroutine that calls `SyncKeys`/`ReportUpdates`). Add a parallel inventory ticker. `serverID`, `agentToken`, and the `*Client` are in scope there:
```go
go func() {
tick := 0
t := time.NewTicker(30 * time.Second)
defer t.Stop()
report := func(static bool) {
r := inventory.Collect(static)
r.ServerId = serverID
r.AgentToken = agentToken
if err := client.ReportInventory(r); err != nil {
log.Printf("report inventory: %v", err)
}
}
report(true) // send a full snapshot on startup
for range t.C {
tick++
report(tick%30 == 0) // every 30th tick = 15 min → include static
}
}()
```
Add imports `"github.com/mrhid6/vantage/agent/internal/inventory"`, `time`, `log` if missing. Match variable names to the actual loop (e.g. the client may be named `c`).
- [ ] **Step 3: Verify build**
Run: `cd agent && go build ./... && go vet ./...`
Expected: success.
- [ ] **Step 4: Commit**
```bash
git add agent/internal/grpc/client.go agent/
git commit -m "feat(agent): schedule inventory reporting (30s metrics, 15m static)"
```
---
## Task 5: Frontend — inventory panel on server detail
**Files:**
- Modify: `web/lib/api.ts` (extend the `Server`/server-detail type with `inventory`)
- Modify: `web/app/servers/[id]/page.tsx` (add panel; enable polling)
**Interfaces:**
- Consumes: server-detail query.
- [ ] **Step 1: Add the inventory type**
In `web/lib/api.ts`, add and attach to the server type used by the detail page:
```ts
export interface Inventory {
cpu: { model?: string; cores?: number; usage_pct: number; load1?: number };
memory: { total_bytes: number; used_bytes: number };
swap_total_bytes: number;
swap_used_bytes: number;
partitions?: { device: string; mountpoint: string; fstype?: string; total_bytes: number; used_bytes: number }[];
kernel?: string;
metrics_at?: string;
static_at?: string;
}
```
Add `inventory?: Inventory;` to the server detail interface.
- [ ] **Step 2: Add a `formatBytes` helper + Inventory panel**
In `web/app/servers/[id]/page.tsx`, add a helper and a panel component. Enable polling on the server-detail `useQuery` with `refetchInterval: 30000`.
```tsx
function formatBytes(n: number): string {
if (!n) return "0 B";
const u = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(1024));
return `${(n / Math.pow(1024, i)).toFixed(1)} ${u[i]}`;
}
function UsageBar({ used, total }: { used: number; total: number }) {
const pct = total > 0 ? Math.min(100, (used / total) * 100) : 0;
return (
<div className="h-2 w-full overflow-hidden rounded-full bg-surface-2">
<div className={`h-full rounded-full ${pct > 90 ? "bg-danger" : "bg-accent"}`} style={{ width: `${pct}%` }} />
</div>
);
}
function InventoryPanel({ inv }: { inv: Inventory }) {
return (
<Card>
<h2 className="mb-4 text-lg font-semibold text-text-primary">Inventory</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">CPU</span><span className="text-text-primary">{inv.cpu.usage_pct.toFixed(0)}%</span></div>
<UsageBar used={inv.cpu.usage_pct} total={100} />
<p className="mt-1 text-xs text-text-secondary">{inv.cpu.model} · {inv.cpu.cores} cores · load {inv.cpu.load1?.toFixed(2)}</p>
</div>
<div>
<div className="mb-1 flex justify-between text-sm"><span className="text-text-secondary">Memory</span><span className="text-text-primary">{formatBytes(inv.memory.used_bytes)} / {formatBytes(inv.memory.total_bytes)}</span></div>
<UsageBar used={inv.memory.used_bytes} total={inv.memory.total_bytes} />
<div className="mb-1 mt-3 flex justify-between text-sm"><span className="text-text-secondary">Swap</span><span className="text-text-primary">{formatBytes(inv.swap_used_bytes)} / {formatBytes(inv.swap_total_bytes)}</span></div>
<UsageBar used={inv.swap_used_bytes} total={inv.swap_total_bytes} />
</div>
</div>
{inv.partitions && inv.partitions.length > 0 && (
<div className="mt-5">
<h3 className="mb-2 text-sm font-medium text-text-secondary">Partitions</h3>
<div className="space-y-3">
{inv.partitions.map((p) => (
<div key={p.mountpoint}>
<div className="mb-1 flex justify-between text-xs">
<span className="font-mono text-text-primary">{p.mountpoint}</span>
<span className="text-text-secondary">{formatBytes(p.used_bytes)} / {formatBytes(p.total_bytes)} · {p.fstype}</span>
</div>
<UsageBar used={p.used_bytes} total={p.total_bytes} />
</div>
))}
</div>
</div>
)}
{inv.kernel && <p className="mt-4 text-xs text-text-secondary">Kernel {inv.kernel}</p>}
</Card>
);
}
```
Render `{server.inventory && <InventoryPanel inv={server.inventory} />}` in the page body (ensure `Card`, `Inventory` are imported). Match how the page currently reads the server object.
- [ ] **Step 3: Verify build**
Run: `cd web && npm run build`
Expected: success.
- [ ] **Step 4: Commit**
```bash
git add web/lib/api.ts web/app/servers/[id]/page.tsx
git commit -m "feat(web): inventory panel on server detail"
```
---
## Task 6: End-to-end manual verification
- [ ] **Step 1: Build all**
Run: `cd server && go build ./... && cd ../agent && go build ./... && cd ../web && npm run build`
Expected: all succeed.
- [ ] **Step 2: Smoke (if environment available)**
With server + Mongo + a connected Linux agent: within ~30s the server detail page shows CPU %, RAM/swap bars; within 15 min (or on agent restart, which sends a full snapshot immediately) partitions, CPU model and kernel appear. Confirm metrics update roughly every 30s.
- [ ] **Step 3: Commit any fixes**
```bash
git add -A
git commit -m "fix: fleet inventory verification fixes"
```
---
# Service Monitoring (uptime-kuma replacement)
Extends the fleet work: in-app service monitors replacing uptime-kuma. Monitors (HTTP/TCP/ICMP/TLS) run **server-side** (public endpoints) or **agent-side** (agent probes its own host). Both runners feed one server-side ingest pipeline: state → incidents → rollups → notifications.
**Design:** validated in brainstorm 2026-07-21. Hybrid runners, all 4 check types, latest+incidents+rollups history, multi-channel notify (webhook/SMTP/Discord/Slack/Telegram), dedicated `SyncMonitors`/`ReportChecks` RPCs.
**Build order — 3 phases, each shippable:**
- **P1 (Tasks 710):** data model, checker pkg, server scheduler, ingest pipeline, `/monitors` UI. Server-run only. No agent, no notify.
- **P2 (Tasks 1112):** `SyncMonitors` + `ReportChecks` RPCs, agent checker + scheduler, agent-run monitors bound to a server.
- **P3 (Tasks 1314):** notification channels + dispatch + settings UI.
## Monitoring Global Constraints
- Same as fleet: no tests this iteration; verify with `go build ./...`, `go vet ./...`, `npm run build`. JSON-codec gRPC — edit both pb files identically, mirror `ReportUpdates` wiring. Separate Go modules, so the checker pkg is **duplicated** in `server/` and `agent/` (same convention as pb files).
- Reuse existing patterns: REST handlers like `server/internal/api`, services like `server/internal/services/servers.go`, `db.Col(...)`, react-query + Tailwind UI like `web/app/servers`.
---
## Task 7: Monitoring data model + checker package (server)
**Files:**
- Create: `server/internal/models/monitor.go`
- Create: `server/internal/checker/checker.go` (+ `http.go`, `tcp.go`, `icmp.go`, `tls.go`)
**Interfaces:**
- Produces: `models.Monitor` (+ `MonitorState`, `MonitorTarget`), `models.Incident`, `models.Rollup`. `checker.Run(ctx, models.Monitor) checker.Result` where `Result{Up bool; LatencyMs int; Message string; CertExpiry *time.Time}`.
- [ ] **Step 1: Model**
```go
type MonitorTarget struct {
URL string `bson:"url,omitempty" json:"url,omitempty"`
Host string `bson:"host,omitempty" json:"host,omitempty"`
Port int `bson:"port,omitempty" json:"port,omitempty"`
Method string `bson:"method,omitempty" json:"method,omitempty"`
ExpectedStatus int `bson:"expected_status,omitempty" json:"expected_status,omitempty"`
Keyword string `bson:"keyword,omitempty" json:"keyword,omitempty"`
TLSWarnDays int `bson:"tls_warn_days,omitempty" json:"tls_warn_days,omitempty"`
}
type MonitorState struct {
Status string `bson:"status" json:"status"` // up|down|pending
LastCheckAt *time.Time `bson:"last_check_at,omitempty" json:"last_check_at,omitempty"`
LatencyMs int `bson:"latency_ms" json:"latency_ms"`
Message string `bson:"message,omitempty" json:"message,omitempty"`
CertExpiryAt *time.Time `bson:"cert_expiry_at,omitempty" json:"cert_expiry_at,omitempty"`
Fails int `bson:"fails" json:"fails"` // consecutive failures
}
type Monitor struct {
MonitorID string `bson:"monitor_id" json:"monitor_id"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"` // http|tcp|icmp|tls
Target MonitorTarget `bson:"target" json:"target"`
IntervalSec int `bson:"interval_sec" json:"interval_sec"`
Runner string `bson:"runner" json:"runner"` // "server" or a server_id
Retries int `bson:"retries" json:"retries"` // consecutive fails before down
Enabled bool `bson:"enabled" json:"enabled"`
ChannelIDs []string `bson:"channel_ids,omitempty" json:"channel_ids,omitempty"`
State MonitorState `bson:"state" json:"state"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type Incident struct {
IncidentID string `bson:"incident_id" json:"incident_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
ResolvedAt *time.Time `bson:"resolved_at,omitempty" json:"resolved_at,omitempty"`
Cause string `bson:"cause,omitempty" json:"cause,omitempty"`
}
type Rollup struct {
MonitorID string `bson:"monitor_id" json:"monitor_id"`
PeriodStart time.Time `bson:"period_start" json:"period_start"` // hour bucket
Checks int `bson:"checks" json:"checks"`
UpCount int `bson:"up_count" json:"up_count"`
SumLatency int64 `bson:"sum_latency" json:"sum_latency"`
}
```
- [ ] **Step 2: Checker package** — `Run(ctx, m)` switches on `m.Type`:
- **http**: `http.Client` GET/HEAD `m.Target.URL`, assert status == ExpectedStatus (default 200), optional `Keyword` body contains; capture TLS peer cert expiry when https.
- **tcp**: `net.DialTimeout("tcp", host:port)`, latency = dial time.
- **icmp**: raw ICMP echo (agent/server run as root). Fall back to `net.Dial("ip4:icmp")`; on permission error return down with message.
- **tls**: `tls.Dial`, read `ConnectionState().PeerCertificates[0].NotAfter` → `CertExpiry`; down if within `TLSWarnDays` or expired.
- All: wrap with per-check timeout (min(IntervalSec, 10s)); `Result.Message` = short reason on failure.
- [ ] **Step 3: Verify build** — `cd server && go build ./... && go vet ./...`
- [ ] **Step 4: Commit** — `feat(server): monitor model + checker package`
---
## Task 8: Ingest pipeline + rollups service
**Files:**
- Create: `server/internal/services/monitors.go`
**Interfaces:**
- Produces: `IngestResult(monitorID string, res checker.Result) error` — the single entry both runners use. `ListMonitors`, `GetMonitor`, `CreateMonitor`, `UpdateMonitor`, `DeleteMonitor`, `ListIncidents(monitorID)`, `UptimeRollups(monitorID, since)`.
- [ ] **Step 1: `IngestResult`** — load monitor; compute new status with `Retries` threshold (increment `state.Fails` on failure, flip to `down` only when `Fails >= Retries`; reset + flip `up` on success). On **transition**: open incident (`down`) or resolve open incident (`up`), and enqueue notification (P3 — leave a `// TODO(P3): dispatch` hook now). Always `$set` state fields. Upsert current-hour `Rollup` (`$inc` checks/up_count/sum_latency). Use `db.Col("monitors")`, `db.Col("incidents")`, `db.Col("monitor_rollups")`, `context.WithTimeout`.
- [ ] **Step 2: CRUD + queries** — standard service funcs mirroring `services/servers.go`. `UptimeRollups` aggregates buckets since a cutoff → uptime % + avg latency series.
- [ ] **Step 3: Verify build** — `go build ./... && go vet ./...`
- [ ] **Step 4: Commit** — `feat(server): monitor ingest pipeline, incidents, rollups`
---
## Task 9: Server scheduler + REST API
**Files:**
- Create: `server/internal/monitorsched/scheduler.go`
- Create: `server/internal/api/monitors.go`
- Modify: server bootstrap (wherever services/gRPC start) to launch the scheduler; router registration where `api` routes are mounted.
**Interfaces:**
- Produces: a scheduler that ticks enabled `runner=="server"` monitors on their `IntervalSec` and calls `checker.Run` → `services.IngestResult`. REST: `GET/POST /api/monitors`, `GET/PUT/DELETE /api/monitors/:id`, `GET /api/monitors/:id/incidents`, `GET /api/monitors/:id/uptime`.
- [ ] **Step 1: Scheduler** — on boot load monitors; per-monitor goroutine or a min-heap wheel keyed on next-run. Only `runner=="server"`. Reload on CRUD (simplest: re-read every N sec, or a reload channel fired by the service). Skip disabled.
- [ ] **Step 2: REST handlers** — mirror an existing `server/internal/api` handler file for style + auth middleware. JSON in/out of `models.Monitor`.
- [ ] **Step 3: Verify build** — `go build ./... && go vet ./...`
- [ ] **Step 4: Commit** — `feat(server): server-run monitor scheduler + REST API`
---
## Task 10: Frontend — monitors UI (P1)
**Files:**
- Modify: `web/lib/api.ts` (Monitor types + bindings)
- Create: `web/app/monitors/page.tsx` (list), `web/app/monitors/[id]/page.tsx` (detail), `web/app/monitors/new/page.tsx` (create/edit form)
- Modify: main nav to add **Monitors** (same place Steps was added)
**Interfaces:**
- Consumes: `/api/monitors*` (T9).
- [ ] **Step 1: Types + api bindings** — `Monitor`, `MonitorState`, `Incident`, uptime series; `api.monitors.list/get/create/update/remove/incidents/uptime`.
- [ ] **Step 2: List page** — table: name, type, status badge (up/down/pending), uptime % (24h), latency, last check. `refetchInterval: 30000`.
- [ ] **Step 3: Detail page** — status header, heartbeat/uptime bars (24h + 30d from rollups), latency chart, incident timeline, cert expiry, assigned channels (read-only until P3).
- [ ] **Step 4: Create/edit form** — type-dependent fields (URL vs host/port), interval, retries, runner select (`server` or a registered server for agent-run — server option only wired in P2), enabled.
- [ ] **Step 5: Verify build** — `cd web && npm run build`
- [ ] **Step 6: Commit** — `feat(web): monitors list/detail/form UI`
---
## Task 11: SyncMonitors + ReportChecks RPCs (P2)
**Files:**
- Modify: `proto/vantage/v1/vantage.proto`, `server/internal/grpc/pb/vantage.pb.go`, `agent/internal/grpc/pb/vantage.pb.go`, `server/internal/grpc/server.go`, `agent/internal/grpc/client.go`
**Interfaces:**
- Produces: `SyncMonitors(server_id, agent_token) -> repeated MonitorSpec`; `ReportChecks(server_id, agent_token, repeated CheckResult) -> ReportChecksResponse`. `MonitorSpec{monitor_id, type, target fields, interval_sec, retries}`. `CheckResult{monitor_id, up, latency_ms, message, cert_expiry_unix}`.
- [ ] **Step 1: pb structs + proto** — add messages to both pb files + proto doc.
- [ ] **Step 2: Wire both RPCs** — mirror `ReportUpdates` plumbing (interface, Unimplemented stub, client method, `Vantage_ServiceDesc.Methods`, `_Vantage_*_Handler`) in both pb files. Server handlers on `vantageServer` (after `ReportUpdates` at server.go:78): `SyncMonitors` returns monitors where `runner==req.ServerId && enabled`; `ReportChecks` validates token then loops `services.IngestResult`. Client methods on `*Client` in client.go (after `ReportUpdates` at client.go:117).
- [ ] **Step 3: Verify build** — both modules `go build ./... && go vet ./...`
- [ ] **Step 4: Commit** — `feat(proto): SyncMonitors + ReportChecks RPCs`
---
## Task 12: Agent checker + scheduler (P2)
**Files:**
- Create: `agent/internal/checker/` (duplicate of server checker pkg)
- Create: `agent/internal/monitors/monitors.go` (poll + run + report loop)
- Modify: agent main loop to start it (alongside the sync loop in `agent/internal/sync` / the inventory ticker from Task 4)
**Interfaces:**
- Consumes: `client.SyncMonitors`, `client.ReportChecks`, agent `checker`.
- [ ] **Step 1: Duplicate checker pkg** into agent module (identical logic; imports agent pb).
- [ ] **Step 2: Monitor loop** — poll `SyncMonitors` every 30s for assigned specs; per-spec ticker on `IntervalSec` runs `checker.Run`; batch `CheckResult`s and `ReportChecks`. `serverID`/`agentToken`/`*Client` in scope from the existing loop.
- [ ] **Step 3: Verify build** — `cd agent && go build ./... && go vet ./...` (+ `GOOS=windows go build ./...`; icmp may no-op on Windows).
- [ ] **Step 4: Commit** — `feat(agent): agent-run monitor scheduler`
---
## Task 13: Notification channels + dispatch (P3)
**Files:**
- Create: `server/internal/models/channel.go`, `server/internal/services/channels.go`, `server/internal/notify/` (`dispatch.go`, `webhook.go`, `smtp.go`, `discord.go`, `slack.go`, `telegram.go`), `server/internal/api/channels.go`
- Modify: `server/internal/services/monitors.go` (replace the P2 `// TODO(P3): dispatch` hook)
**Interfaces:**
- Produces: `models.NotificationChannel{channel_id, name, type, config map, enabled}`. `notify.Dispatch(channel, event)` where `event` = monitor + old/new status + message. `notify.Test(channel)`.
- [ ] **Step 1: Model + CRUD service + REST** (`/api/channels*`, incl. `POST /api/channels/:id/test`).
- [ ] **Step 2: Dispatch abstraction** — webhook/discord/slack/telegram are HTTP POST with per-type JSON payload; SMTP via `net/smtp`. Per-monitor routing via `monitor.ChannelIDs`; resend interval so an ongoing `down` re-alerts at most every N min (track `last_notified_at` on monitor state).
- [ ] **Step 3: Fire on transition** — in `IngestResult`, on up/down flip resolve channels and `notify.Dispatch` each (goroutine, best-effort, log failures).
- [ ] **Step 4: Verify build** — `go build ./... && go vet ./...`
- [ ] **Step 5: Commit** — `feat(server): multi-channel monitor notifications`
---
## Task 14: Frontend — notification settings (P3)
**Files:**
- Modify: `web/lib/api.ts` (channel types + bindings), `web/app/settings/` (add notifications section/page)
- Modify: monitor create/edit form (Task 10) to select channels
**Interfaces:**
- Consumes: `/api/channels*`.
- [ ] **Step 1: Channel types + api bindings.**
- [ ] **Step 2: Settings UI** — list/add/edit channels, type-dependent config fields, **Test** button hitting `/api/channels/:id/test`.
- [ ] **Step 3: Wire channel multi-select** into the monitor form.
- [ ] **Step 4: Verify build** — `cd web && npm run build`
- [ ] **Step 5: Commit** — `feat(web): notification channel settings UI`
---
## Self-Review Notes
- **Spec coverage:** §3 model → T1; §4 RPC → T1; §5 collectors + scheduler → T3, T4; §6 handler/store → T2; §7 frontend → T5. Split cadence (30s metrics / 15m static) in T4 scheduler; merge rules preserving static in T2 `StoreInventory`. Tests omitted per Global Constraints.
- **Startup snapshot:** agent sends `Collect(true)` immediately so static fields populate without waiting 15 min.
- **Types consistent:** `InventoryReport` field names identical across proto, both pb files, store service, and TS interface (`usage_pct`, `used_bytes`, `total_bytes`, `swap_*`).
- **Follow-ups (out of scope):** time-series history, usage alerting, Windows collectors, servers-list CPU/RAM badges.
- **Monitoring (Tasks 714):** hybrid runner service-monitor replacing uptime-kuma, added 2026-07-21. 3 phases — P1 server-run engine+UI (T710), P2 agent-run RPCs (T1112), P3 multi-channel notify (T1314). Single `IngestResult` pipeline for both runners; checker pkg duplicated per module (pb convention). Design: brainstorm 2026-07-21. Follow-ups out of scope: status pages, maintenance windows, per-check auth headers, ICMP on Windows.
File diff suppressed because it is too large Load Diff
@@ -1,142 +0,0 @@
# Fleet Inventory — Design
**Date:** 2026-07-20
**Status:** Approved (design) — ready for implementation planning
**Scope:** Fleet Inventory only. Server Workflows and SaaS/auth are separate sub-projects.
---
## 1. Summary
Each agent collects hardware/OS inventory about its host and reports it to the server, which stores the latest snapshot per server and surfaces it in the UI. Two cadences:
- **Metrics (near-real-time):** CPU load/usage, RAM used/total, swap used/total — every **30s** (aligned with existing poll rhythm).
- **Static inventory (slow):** disks, partitions and their usage, CPU model/cores, total RAM, OS details — every **15 min**.
Transport: a **new unary gRPC `ReportInventory` RPC** (mirrors the existing `ReportUpdates` pattern). No streaming.
---
## 2. Locked decisions
| Topic | Decision |
|-------|----------|
| Transport | New `ReportInventory` unary RPC. |
| Cadence | Metrics every 30s; static inventory every 15 min. One RPC carries both, but static fields are only populated on the 15-min tick (empty/omitted otherwise → server keeps prior static snapshot). |
| Storage | Latest snapshot embedded on the `servers` document (`inventory` sub-doc). No history/time-series in v1. |
| Collection | Pure-Go where practical (`/proc`, `gopsutil`-style). Agent already runs as root. |
| Platform | Linux primary; Windows agent populates what it can, leaves the rest empty. |
---
## 3. Data model
Add an `Inventory` sub-document to the existing `Server` model (`server/internal/models/server.go`):
```go
type CPUInfo struct {
Model string `bson:"model,omitempty" json:"model,omitempty"`
Cores int `bson:"cores,omitempty" json:"cores,omitempty"`
UsagePct float64 `bson:"usage_pct" json:"usage_pct"` // metrics tick
Load1 float64 `bson:"load1,omitempty" json:"load1,omitempty"`
}
type MemInfo struct {
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"` // metrics tick
}
type Partition struct {
Device string `bson:"device" json:"device"`
Mountpoint string `bson:"mountpoint" json:"mountpoint"`
Fstype string `bson:"fstype,omitempty" json:"fstype,omitempty"`
TotalBytes uint64 `bson:"total_bytes" json:"total_bytes"`
UsedBytes uint64 `bson:"used_bytes" json:"used_bytes"`
}
type Inventory struct {
CPU CPUInfo `bson:"cpu" json:"cpu"`
Memory MemInfo `bson:"memory" json:"memory"`
SwapTotalBytes uint64 `bson:"swap_total_bytes" json:"swap_total_bytes"`
SwapUsedBytes uint64 `bson:"swap_used_bytes" json:"swap_used_bytes"`
Partitions []Partition `bson:"partitions,omitempty" json:"partitions,omitempty"`
Kernel string `bson:"kernel,omitempty" json:"kernel,omitempty"`
MetricsAt *time.Time `bson:"metrics_at,omitempty" json:"metrics_at,omitempty"`
StaticAt *time.Time `bson:"static_at,omitempty" json:"static_at,omitempty"`
}
```
Add `Inventory *Inventory` field to `Server`.
Server-side update rules:
- Metrics fields (`cpu.usage_pct`, `cpu.load1`, `memory.used_bytes`, swap used) always updated + `metrics_at`.
- Static fields (`cpu.model/cores`, `memory.total_bytes`, `partitions`, `kernel`, swap total) updated only when the report includes them (non-zero/non-empty) + `static_at`.
---
## 4. gRPC protocol (`proto/vantage/v1/vantage.proto` + both `pb.go` files)
```protobuf
rpc ReportInventory(InventoryReport) returns (InventoryReportResponse);
message InventoryReport {
string server_id = 1;
string agent_token = 2;
bool include_static = 3; // true on the 15-min tick
CPUReport cpu = 4;
MemReport memory = 5;
uint64 swap_total = 6;
uint64 swap_used = 7;
repeated PartitionReport partitions = 8; // only when include_static
string kernel = 9; // only when include_static
}
message CPUReport { string model = 1; int32 cores = 2; double usage_pct = 3; double load1 = 4; }
message MemReport { uint64 total_bytes = 1; uint64 used_bytes = 2; }
message PartitionReport { string device = 1; string mountpoint = 2; string fstype = 3; uint64 total_bytes = 4; uint64 used_bytes = 5; }
message InventoryReportResponse {}
```
Hand-written JSON-codec structs added to `server/internal/grpc/pb/vantage.pb.go` and `agent/internal/grpc/pb/vantage.pb.go`, plus the RPC method wiring (service interface, client method, handler registration) mirroring `ReportUpdates`.
---
## 5. Agent collection (`agent/internal/inventory/`)
- `Collect(includeStatic bool) *pb.InventoryReport` — reads:
- CPU usage: sample `/proc/stat` delta; load from `/proc/loadavg`; model/cores from `/proc/cpuinfo` (static).
- Memory/swap: `/proc/meminfo`.
- Partitions: `/proc/mounts` filtered to real filesystems + `statfs` for total/used (static).
- Kernel: `uname` / `/proc/version` (static).
- Windows: best-effort via `wmic`/PS or leave empty.
- Scheduler in the agent main loop: a 30s ticker calls `Collect(false)` and `ReportInventory`; every 30th tick (15 min) calls `Collect(true)`.
- Reuse existing gRPC client; add `Client.ReportInventory(...)` like `ReportUpdates`.
Prefer implementing the `/proc` readers directly (no new heavy deps) unless a `gopsutil` dependency is already vendored.
---
## 6. Server handler + service
- gRPC handler `ReportInventory` in `server/internal/grpc/server.go`: validate agent token (`ValidateAgentToken`), then call `services.StoreInventory(serverID, report)`.
- `services.StoreInventory` (in `server/internal/services/inventory.go`): builds the `$set` per the update rules in §3 and `UpdateOne` on `servers`.
---
## 7. Frontend
Surface inventory on the existing server detail page (`web/app/servers/[id]/page.tsx`) — add an "Inventory" panel:
- CPU usage gauge + model/cores, load.
- RAM used/total bar, swap bar.
- Partitions table: device, mount, fstype, used/total with a usage bar.
- "Updated Xs ago" from `metrics_at`/`static_at`.
Optionally add compact CPU/RAM badges to the servers list (`web/app/servers/page.tsx`). Reuse `@/components/ui` + Tailwind tokens. Poll the server detail query while the page is open (react-query `refetchInterval` ~30s) so metrics stay fresh.
---
## 8. Out of scope
- Time-series history / graphs (only latest snapshot stored).
- Alerting thresholds on usage (settings/alerts is a separate concern).
- Per-process / network / GPU inventory.
- Tests (skipped, consistent with the Workflows iteration).
@@ -1,142 +0,0 @@
# SaaS: Auth + Organizations — Design
**Date:** 2026-07-20
**Status:** Approved (design) — ready for implementation planning
**Scope:** Local auth + organizations + per-org OIDC, and org-scoping of existing data. Billing/plan-limits explicitly deferred. Fleet Inventory and Server Workflows are separate sub-projects.
---
## 1. Summary
Turn Vantage from a single-admin, single global-OIDC tool into a multi-tenant app:
1. **Replace** the global Authentik/env-based OIDC with **local email/password accounts** as the primary login.
2. **Organizations** — every user belongs to an org; every domain object (servers, keys, secrets, assignments, workflows, steps, runs, audit) carries an `org_id` and all queries are scoped to the caller's org.
3. **Per-org OpenID** — an org admin can configure their own OIDC provider (issuer/client id/secret); users in that org can then sign in through it.
No billing, no seat/server limits this iteration (schema leaves room).
---
## 2. Locked decisions
| Topic | Decision |
|-------|----------|
| Primary auth | Local email + password (bcrypt). Replaces global Authentik. |
| Org SSO | Per-org OIDC provider, configured by org admin, resolved dynamically at login. |
| Isolation | `org_id` on every collection; every service query filtered by org. Enforced in the request layer via session→org. |
| Roles | `owner`, `admin`, `member` (v1: owner/admin can manage users + org OIDC + all resources; member can use resources). Keep minimal. |
| Bootstrapping | First-run creates the initial org + owner account (setup flow) when no users exist. |
| Sessions | Keep existing Redis session store; session now carries `user_id`, `org_id`, `role`, `email`. |
| Agent auth | Unchanged (per-server agent tokens). Servers gain `org_id`; agent RPCs resolve org from the server record. |
---
## 3. Data model
### `orgs`
```json
{ "_id":"ObjectId", "org_id":"uuid", "name":"Acme", "created_at":"ISODate" }
```
### `users`
```json
{
"_id":"ObjectId", "user_id":"uuid", "org_id":"uuid",
"email":"a@b.com", "password_hash":"bcrypt...", "role":"owner|admin|member",
"auth_source":"local|oidc", "created_at":"ISODate", "last_login":"ISODate|null"
}
```
Unique index on `email` (global — email identifies the account and its org).
### `org_oidc` (per-org provider config)
```json
{
"_id":"ObjectId", "org_id":"uuid",
"issuer":"https://id.acme.com", "client_id":"...",
"client_secret_enc":"AES...", // encrypted with existing crypto.go
"redirect_url":"https://vantage.../auth/oidc/callback",
"enabled": true, "updated_at":"ISODate"
}
```
### Existing collections — add `org_id`
`servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` each gain `org_id string`. A **migration** backfills all existing documents into a default org (see §7).
---
## 4. Auth flows
### Local
- `POST /auth/register` — only allowed during first-run bootstrap (creates org + owner) OR by an org admin inviting a user (see below). Not open self-serve.
- `POST /auth/login` — email + password → verify bcrypt → create session with `{user_id, org_id, role, email}`.
- `POST /auth/logout` — destroy session.
- `GET /auth/me` — returns current user + org.
### Org-admin user management
- `GET /api/org/users` / `POST /api/org/users` (create local user in caller's org) / `PUT /api/org/users/:id/role` / `DELETE /api/org/users/:id`.
### Per-org OIDC
- `GET/PUT /api/org/oidc` — read/save the caller org's provider config (admin only). Secret stored encrypted.
- `GET /auth/oidc/start?org=<org_id or slug>` — look up org's `org_oidc`, build the OIDC provider on demand (cache per org), redirect to authorize.
- `GET /auth/oidc/callback` — exchange code, match/provision the user by email **within that org**, create session.
- If the email exists in the org → log in. If not → provision a `member` with `auth_source=oidc` (org admin can promote). Reject if email belongs to a different org.
### First-run bootstrap
- `GET /auth/bootstrap-status``{ needs_setup: bool }` (true when `users` is empty).
- Setup page collects org name + owner email/password → creates org + owner → session.
---
## 5. Request scoping
- `auth.Middleware` already loads the session; extend `Session` to include `OrgID`, `UserID`, `Role`. Add helper `auth.OrgID(c) string`.
- **Every service function that reads/writes a scoped collection takes an `orgID` argument** and adds `"org_id": orgID` to its filter and on insert. Handlers pass `auth.OrgID(c)`.
- Add a `requireRole(role)` gin middleware for admin-only routes (org user mgmt, org OIDC).
- Agent-facing gRPC: resolve `org_id` from the `servers` record (already tied to `server_id`); inventory/keys/sync operate on that org implicitly.
---
## 6. Removing global Authentik
- Delete/retire env-driven `InitOIDC` global provider (`OIDC_ISSUER` etc.). Keep the `go-oidc`/`oauth2` machinery but move it behind the per-org resolver.
- `authEnabled` global replaced by "auth always on" (there is always local auth). Update `middleware.go` accordingly (no more `if !authEnabled { next }` bypass — except the bootstrap endpoints and login/register which are unauthenticated).
- Login page (`web/app/login` or existing) offers: email/password form + "Sign in with your organization's SSO" (enter org, redirect to `/auth/oidc/start`).
---
## 7. Migration
One-shot migration run at startup (idempotent):
1. If `orgs` is empty AND `servers`/`keys`/etc. contain documents without `org_id`: create a **default org** ("Default").
2. Set `org_id = <default>` on all existing `servers`, `keys`, `assignments`, `secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `audit` documents missing it.
3. If `OIDC_ISSUER` env was set previously and an admin email is known, optionally seed an owner user (documented manual step) — otherwise first-run bootstrap handles owner creation.
Guard with a marker (e.g. a `migrations` collection entry) so it runs once.
---
## 8. Frontend
- **Login/Setup:** `web/app/login/page.tsx` (email/password + org SSO entry) and `web/app/setup/page.tsx` (first-run). Redirect logic based on `bootstrap-status` and `auth/me`.
- **Org settings:** `web/app/settings/org/` — members list + invite/create user + role management; OIDC provider form (issuer/client id/secret/enabled).
- Existing pages unchanged functionally but now implicitly org-scoped by the backend. Show current org + user in the sidebar/header.
---
## 9. Security
- Passwords: bcrypt (cost ≥ 12). Never returned.
- Org OIDC client secret encrypted at rest (reuse `services/crypto.go` AES).
- Cross-org access prevented at the service layer (org_id in every filter) — the primary isolation boundary. Handlers must never accept an `org_id` from the client; always derive from session.
- OIDC callback must bind the returned identity to the org that initiated the flow (state carries org_id) to prevent org-mixing.
- Role checks on all org-admin mutations.
---
## 10. Out of scope
- Billing, plans, seat/server limits.
- Cross-org resource sharing, org switching for a single user (one user = one org in v1).
- SCIM / directory sync, SAML.
- Email delivery for invites (create-user sets a password or invite token; email sending deferred — document as manual/console output).
- Tests (skipped, consistent with prior iterations).
-2
View File
@@ -27,8 +27,6 @@ function Invoke-Native {
}
}
# Like Invoke-Native but never throws — for teardown, where a missing/stopped
# service must not abort the uninstall.
function Invoke-NativeSoft {
param([string]$File, [string[]]$Arguments)
Write-Log ("RUN(soft): {0} {1}" -f $File, ($Arguments -join " "))
+1
View File
@@ -131,6 +131,7 @@ message MonitorSpec {
int32 tls_warn_days = 9;
int32 interval_sec = 10;
int32 retries = 11;
bool insecure = 12;
}
message SyncMonitorsRequest {
+47 -11
View File
@@ -19,23 +19,63 @@ func main() {
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
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")
if err := services.EnsureAuthIndexes(); err != nil {
log.Fatalf("failed to ensure auth indexes: %v", err)
}
if err := services.RunMigrations(); err != nil {
log.Fatalf("migration failed: %v", err)
}
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)
}
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 created, updated, err := services.SeedDefaultSteps(); err != nil {
log.Printf("warning: failed to seed default steps: %v", err)
if orgIDs, err := services.ListOrgIDs(); err != nil {
log.Printf("warning: failed to list orgs for default step seeding: %v", err)
} else {
log.Printf("default steps seeded: %d created, %d updated", created, updated)
for _, orgID := range orgIDs {
if created, updated, err := services.SeedDefaultSteps(orgID); err != nil {
log.Printf("warning: failed to seed default steps for org %s: %v", orgID, err)
} else {
log.Printf("default steps seeded for org %s: %d created, %d updated", orgID, created, updated)
}
}
}
services.StartLogSweeper()
@@ -46,11 +86,7 @@ func main() {
}
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()
@@ -61,17 +97,17 @@ func main() {
}
}()
// Start gRPC server
go func() {
if err := grpcserver.StartGRPC(9090); err != nil {
log.Fatalf("gRPC server error: %v", err)
}
}()
// Start the server-side monitor scheduler.
monitorsched.Start(context.Background())
// Start REST server
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
+97
View File
@@ -0,0 +1,97 @@
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.OrgID(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.OrgID(c), &ch)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, created)
}
func updateChannel(c *gin.Context) {
var body struct {
Name *string `json:"name"`
Type *string `json:"type"`
Config *map[string]string `json:"config"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
upd := bson.M{}
if body.Name != nil {
upd["name"] = *body.Name
}
if body.Type != nil {
upd["type"] = *body.Type
}
if body.Config != nil {
upd["config"] = *body.Config
}
if body.Enabled != nil {
upd["enabled"] = *body.Enabled
}
if len(upd) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := services.UpdateChannel(auth.OrgID(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.OrgID(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.OrgID(c), c.Param("id")); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "sent"})
}
+24 -22
View File
@@ -8,13 +8,14 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
"github.com/wwt/guac"
)
// POST /api/console/connect
// Body: { server_id, protocol, key_id?, rdp_username?, rdp_password? }
// Returns: { session_id, token, ws_path }
func consoleConnect(c *gin.Context) {
var body struct {
ServerID string `json:"server_id" binding:"required"`
@@ -29,13 +30,13 @@ func consoleConnect(c *gin.Context) {
return
}
srv, err := services.GetServer(body.ServerID)
srv, err := services.GetServer(auth.OrgID(c), body.ServerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
sess, err := services.CreateConsoleSession(body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP())
sess, err := services.CreateConsoleSession(auth.OrgID(c), body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -47,20 +48,20 @@ func consoleConnect(c *gin.Context) {
}
if (body.Protocol == "rdp" || body.Protocol == "vnc") && (body.RDPUsername != "" || body.RDPPassword != "") {
if err := services.StashConsoleRDPCreds(sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil {
if err := services.StashConsoleRDPCreds(auth.OrgID(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(sess.SessionID, body.SSHUsername); err != nil {
if err := services.SetConsoleSSHUser(auth.OrgID(c), sess.SessionID, body.SSHUsername); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
services.LogEvent("console.opened", actorFromCtx(c), srv.ServerID, "",
services.LogEvent(auth.OrgID(c), "console.opened", actorFromCtx(c), srv.ServerID, "",
"console session opened ("+body.Protocol+")")
c.JSON(http.StatusOK, gin.H{
@@ -70,8 +71,8 @@ func consoleConnect(c *gin.Context) {
})
}
// queryIntDefault reads a positive integer query param, falling back to def
// when absent, unparseable, or non-positive.
func queryIntDefault(r *http.Request, key string, def int) int {
v, err := strconv.Atoi(r.URL.Query().Get(key))
if err != nil || v <= 0 {
@@ -80,7 +81,7 @@ func queryIntDefault(r *http.Request, key string, def int) int {
return v
}
// GET /api/console/tunnel?token=... (WebSocket upgrade)
func consoleTunnel(c *gin.Context) {
token := c.Query("token")
sessionID, err := services.VerifySessionToken(token)
@@ -88,35 +89,36 @@ func consoleTunnel(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
sess, err := services.GetConsoleSession(sessionID)
orgID := auth.OrgID(c)
sess, err := services.GetConsoleSession(orgID, sessionID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
// User-bound: the caller (authenticated via session cookie) must be the same
// user who opened the session. Blocks a leaked token being used by someone else.
if actor := actorFromCtx(c); actor != sess.User {
c.JSON(http.StatusForbidden, gin.H{"error": "session belongs to another user"})
return
}
// Single-use: atomically spend the token so a replay within its TTL is rejected.
if err := services.ConsumeSessionToken(sessionID); err != nil {
if err := services.ConsumeSessionToken(orgID, sessionID); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"})
return
}
srv, err := services.GetServer(sess.ServerID)
srv, err := services.GetServer(auth.OrgID(c), sess.ServerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
// Decrypt private key + passphrase in-memory only (ssh).
var privKey, passphrase string
if sess.Protocol == "ssh" && sess.KeyID != "" {
privKey, err = services.GetPrivateKey(sess.KeyID)
privKey, err = services.GetPrivateKey(auth.OrgID(c), sess.KeyID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"})
return
@@ -126,7 +128,7 @@ func consoleTunnel(c *gin.Context) {
var rdpUser, rdpPass string
if sess.Protocol == "rdp" || sess.Protocol == "vnc" {
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(sessionID)
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(orgID, sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load credentials"})
return
@@ -143,7 +145,7 @@ func consoleTunnel(c *gin.Context) {
guacdAddr = "guacd:4822"
}
// Build a guac tunnel config from our params.
connect := func(r *http.Request) (guac.Tunnel, error) {
config := guac.NewGuacamoleConfiguration()
config.Protocol = gp.Protocol
@@ -171,7 +173,7 @@ func consoleTunnel(c *gin.Context) {
wsServer := guac.NewWebsocketServer(connect)
wsServer.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) {
_ = services.EndConsoleSession(sessionID)
_ = services.EndConsoleSession(orgID, sessionID)
}
wsServer.ServeHTTP(c.Writer, c.Request)
}
+65 -63
View File
@@ -25,20 +25,18 @@ func RegisterRoutes(r *gin.Engine) {
r.GET("/update", handleUpdateScript)
r.GET("/update.ps1", handleUpdateScriptWindows)
// ESO read endpoint — bearer-token auth, not session auth, so Kubernetes
// External Secrets Operator can call it. Lives under /api (so the reverse
// proxy routes it to the backend) but on a distinct subpath to avoid
// colliding with the session-authed GET /api/secrets/:group. Returns a
// group as flat JSON.
r.GET("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup)
// Auth endpoints (no session required)
r.GET("/auth/login", auth.HandleLogin)
r.GET("/auth/callback", auth.HandleCallback)
r.GET("/auth/logout", auth.HandleLogout)
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())
{
@@ -56,9 +54,13 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.GET("/audit", listAuditEvents)
apiGroup.GET("/settings", getSettings)
apiGroup.PUT("/settings", saveSettings)
apiGroup.POST("/settings/secrets-token", rotateSecretsToken)
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)
@@ -81,11 +83,23 @@ func RegisterRoutes(r *gin.Engine) {
registerWorkflowRoutes(apiGroup)
registerMonitorRoutes(apiGroup)
registerChannelRoutes(apiGroup)
org := apiGroup.Group("/org")
org.Use(auth.RequireRole("owner", "admin"))
{
org.GET("/users", listOrgUsers)
org.POST("/users", createOrgUser)
org.PUT("/users/:id/role", updateOrgUserRole)
org.DELETE("/users/:id", deleteOrgUser)
org.GET("/oidc", getOrgOIDC)
org.PUT("/oidc", putOrgOIDC)
}
}
}
func listServers(c *gin.Context) {
servers, err := services.ListServers()
servers, err := services.ListServers(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -94,7 +108,7 @@ func listServers(c *gin.Context) {
}
func createServer(c *gin.Context) {
s, token, err := services.CreateServer()
s, token, err := services.CreateServer(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -107,21 +121,19 @@ func createServer(c *gin.Context) {
}
func newServer(c *gin.Context) {
s, token, err := services.CreateServer()
s, token, err := services.CreateServer(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
services.LogEvent(auth.OrgID(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://vantage.example.com"
}
host := publicHostFromRequest(c)
installCmd := fmt.Sprintf(
`curl -fsSL "%s/install?server_id=%s&token=%s" | bash`,
@@ -143,15 +155,15 @@ func newServer(c *gin.Context) {
func getServer(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(id)
s, err := services.GetServer(auth.OrgID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
assignments, _ := services.GetAssignmentsWithKeysForServer(id)
assignments, _ := services.GetAssignmentsWithKeysForServer(auth.OrgID(c), id)
// Build response matching ServerWithKeys shape expected by frontend
type serverResponse struct {
*models.Server
Keys interface{} `json:"keys"`
@@ -164,8 +176,8 @@ func getServer(c *gin.Context) {
func deleteServer(c *gin.Context) {
id := c.Param("id")
s, _ := services.GetServer(id)
if err := services.DeleteServer(id); err != nil {
s, _ := services.GetServer(auth.OrgID(c), id)
if err := services.DeleteServer(auth.OrgID(c), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -173,7 +185,7 @@ func deleteServer(c *gin.Context) {
if s != nil {
hostname = s.Hostname
}
services.LogEvent("server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
services.LogEvent(auth.OrgID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -192,7 +204,7 @@ func generateKey(c *gin.Context) {
body.Label = "generated"
}
s, err := services.GetServer(id)
s, err := services.GetServer(auth.OrgID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -210,7 +222,7 @@ func generateKey(c *gin.Context) {
return
}
services.LogEvent("key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
services.LogEvent(auth.OrgID(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,
@@ -219,7 +231,7 @@ func generateKey(c *gin.Context) {
}
func listKeys(c *gin.Context) {
keys, err := services.ListKeys()
keys, err := services.ListKeys(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -239,18 +251,18 @@ func createKey(c *gin.Context) {
return
}
key, err := services.CreateKey(body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase)
key, err := services.CreateKey(auth.OrgID(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("key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label))
services.LogEvent(auth.OrgID(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.OrgID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -260,13 +272,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.OrgID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
return
}
assignments, _ := services.GetAssignmentsWithServers(id)
assignments, _ := services.GetAssignmentsWithServers(auth.OrgID(c), id)
type keyResponse struct {
*models.Key
@@ -280,8 +292,8 @@ func getKey(c *gin.Context) {
func deleteKey(c *gin.Context) {
id := c.Param("id")
k, _ := services.GetKey(id)
if err := services.DeleteKey(id); err != nil {
k, _ := services.GetKey(auth.OrgID(c), id)
if err := services.DeleteKey(auth.OrgID(c), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -289,7 +301,7 @@ func deleteKey(c *gin.Context) {
if k != nil {
label = k.Label
}
services.LogEvent("key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
services.LogEvent(auth.OrgID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -303,12 +315,12 @@ func assignKey(c *gin.Context) {
return
}
a, err := services.AssignKey(keyID, body.ServerID)
a, err := services.AssignKey(auth.OrgID(c), keyID, body.ServerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
services.LogEvent(auth.OrgID(c), "key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
c.JSON(http.StatusCreated, a)
}
@@ -316,11 +328,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.OrgID(c), keyID, serverID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
services.LogEvent(auth.OrgID(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})
}
@@ -335,7 +347,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.OrgID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -346,7 +358,7 @@ func updateAgent(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent("agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
services.LogEvent(auth.OrgID(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,
@@ -355,7 +367,7 @@ func updateAgent(c *gin.Context) {
func applyUpdates(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(id)
s, err := services.GetServer(auth.OrgID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -365,7 +377,7 @@ func applyUpdates(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent("updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
services.LogEvent(auth.OrgID(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"})
}
@@ -397,7 +409,7 @@ if [ -z "$LATEST" ]; then
fi
VERSION="${LATEST#agent/}"
LATEST_ENCODED="${LATEST/\//%%2F}"
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"
@@ -432,7 +444,7 @@ func listAuditEvents(c *gin.Context) {
limit = n
}
}
events, err := services.ListAuditEvents(limit)
events, err := services.ListAuditEvents(auth.OrgID(c), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -441,7 +453,7 @@ func listAuditEvents(c *gin.Context) {
}
func getSettings(c *gin.Context) {
s, err := services.GetSettings()
s, err := services.GetSettings(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -459,11 +471,11 @@ func saveSettings(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.SaveSettings(body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil {
if err := services.SaveSettings(auth.OrgID(c), body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("settings.updated", actorFromCtx(c), "", "", "alert settings updated")
services.LogEvent(auth.OrgID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated")
c.JSON(http.StatusOK, gin.H{"saved": true})
}
@@ -475,14 +487,7 @@ func handleInstallScript(c *gin.Context) {
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
publicHost := os.Getenv("PUBLIC_HOST")
if publicHost == "" {
publicHost = "vantage.example.com"
}
grpcHost := os.Getenv("GRPC_HOST")
if grpcHost == "" {
grpcHost = publicHost
}
script := fmt.Sprintf(`#!/usr/bin/env bash
set -euo pipefail
@@ -490,9 +495,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://}"
@@ -514,7 +516,7 @@ if [ -z "$LATEST" ]; then
fi
VERSION="${LATEST#agent/}"
LATEST_ENCODED="${LATEST/\//%%2F}"
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"
@@ -565,7 +567,7 @@ systemctl daemon-reload
systemctl enable --now vantage-agent
echo "vantage-agent installed and started."
`, serverID, token, giteaHost, publicHost, grpcHost)
`, serverID, token, giteaHost, grpcHost)
c.Header("Content-Type", "text/x-shellscript")
c.String(http.StatusOK, script)
+4 -9
View File
@@ -16,13 +16,8 @@ func handleInstallScriptWindows(c *gin.Context) {
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
grpcHost := os.Getenv("GRPC_HOST")
if grpcHost == "" {
grpcHost = os.Getenv("PUBLIC_HOST")
}
if grpcHost == "" {
grpcHost = "vantage.example.com"
}
script := fmt.Sprintf(
"#Requires -RunAsAdministrator\n"+
@@ -55,9 +50,9 @@ func handleInstallScriptWindows(c *gin.Context) {
c.String(http.StatusOK, script)
}
// handleUpdateScriptWindows serves a PowerShell one-liner that upgrades an
// already-installed Windows agent. No server_id/token needed: the MSI is a
// MajorUpgrade and setup.ps1 preserves the existing config on upgrade.
func handleUpdateScriptWindows(c *gin.Context) {
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
+26 -7
View File
@@ -5,6 +5,7 @@ import (
"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"
@@ -21,7 +22,7 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
}
func listMonitors(c *gin.Context) {
monitors, err := services.ListMonitors()
monitors, err := services.ListMonitors(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -39,7 +40,7 @@ func createMonitor(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
return
}
created, err := services.CreateMonitor(&m)
created, err := services.CreateMonitor(auth.OrgID(c), &m)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -48,7 +49,7 @@ func createMonitor(c *gin.Context) {
}
func getMonitor(c *gin.Context) {
m, err := services.GetMonitor(c.Param("id"))
m, err := services.GetMonitor(auth.OrgID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -104,7 +105,7 @@ func updateMonitor(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := services.UpdateMonitor(c.Param("id"), upd); err != nil {
if err := services.UpdateMonitor(auth.OrgID(c), c.Param("id"), upd); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -112,7 +113,7 @@ func updateMonitor(c *gin.Context) {
}
func deleteMonitor(c *gin.Context) {
if err := services.DeleteMonitor(c.Param("id")); err != nil {
if err := services.DeleteMonitor(auth.OrgID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -120,7 +121,16 @@ func deleteMonitor(c *gin.Context) {
}
func getMonitorIncidents(c *gin.Context) {
incidents, err := services.ListIncidents(c.Param("id"), 50)
m, err := services.GetMonitor(auth.OrgID(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.OrgID(c), c.Param("id"), 50)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -129,8 +139,17 @@ func getMonitorIncidents(c *gin.Context) {
}
func getMonitorUptime(c *gin.Context) {
m, err := services.GetMonitor(auth.OrgID(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(c.Param("id"), since)
rollups, err := services.UptimeRollups(auth.OrgID(c), c.Param("id"), since)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+159
View File
@@ -0,0 +1,159 @@
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 listOrgUsers(c *gin.Context) {
users, err := services.ListUsers(auth.OrgID(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 createOrgUser(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.OrgID(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 updateOrgUserRole(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
}
orgID, targetID := auth.OrgID(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.GetUserInOrg(orgID, 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(orgID, targetID, body.Role); err != nil {
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func deleteOrgUser(c *gin.Context) {
orgID, targetID := auth.OrgID(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.GetUserInOrg(orgID, 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(orgID, 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 getOrgOIDC(c *gin.Context) {
cfg, err := services.GetOrgOIDC(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusOK, gin.H{"enabled": false, "client_secret_set": false})
return
}
c.JSON(http.StatusOK, gin.H{
"org_id": cfg.OrgID,
"issuer": cfg.Issuer,
"client_id": cfg.ClientID,
"enabled": cfg.Enabled,
"updated_at": cfg.UpdatedAt,
"client_secret_set": cfg.ClientSecretEnc != "",
})
}
func putOrgOIDC(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.SaveOrgOIDC(auth.OrgID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
auth.EvictOIDCProvider(auth.OrgID(c))
c.JSON(http.StatusOK, gin.H{"saved": true})
}
+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()
}
+45 -27
View File
@@ -7,40 +7,58 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
)
// groupNamePattern restricts group and key names to characters that are safe
// in URLs and Kubernetes/env contexts.
var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
func validName(s string) bool {
return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s)
}
// secretsReadAuth validates the ESO bearer token on the public read endpoint.
const ctxSecretsOrgKey = "km_secrets_org"
func secretsReadAuth() gin.HandlerFunc {
return func(c *gin.Context) {
const prefix = "Bearer "
auth := c.GetHeader("Authorization")
if len(auth) <= len(prefix) || !strings.EqualFold(auth[:len(prefix)], prefix) {
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
}
if !services.VerifySecretsReadToken(auth[len(prefix):]) {
orgID, ok := services.ResolveSecretsReadToken(authHeader[len(prefix):])
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set(ctxSecretsOrgKey, orgID)
c.Next()
}
}
// esoGetGroup handles GET /secrets/:group for the External Secrets Operator.
// Returns a flat JSON object { "KEY": "value", ... }; 404 if the group is empty
// (ESO treats 404 as "deleted").
func esoGetGroup(c *gin.Context) {
group := c.Param("group")
values, err := services.GetSecretGroupDecrypted(group)
orgID := c.GetString(ctxSecretsOrgKey)
if orgID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
values, err := services.GetSecretGroupDecrypted(orgID, group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
return
@@ -53,7 +71,7 @@ func esoGetGroup(c *gin.Context) {
}
func listSecretGroups(c *gin.Context) {
groups, err := services.ListSecretGroups()
groups, err := services.ListSecretGroups(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -61,8 +79,8 @@ func listSecretGroups(c *gin.Context) {
c.JSON(http.StatusOK, groups)
}
// createSecretGroup handles POST /api/secrets. A group is implicit, so it must
// be created with at least one key/value pair.
func createSecretGroup(c *gin.Context) {
var body struct {
Group string `json:"group" binding:"required"`
@@ -86,17 +104,17 @@ func createSecretGroup(c *gin.Context) {
return
}
}
if err := services.UpsertSecrets(body.Group, body.Values); err != nil {
if err := services.UpsertSecrets(auth.OrgID(c), body.Group, body.Values); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
services.LogEvent(auth.OrgID(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(group)
secrets, err := services.GetSecretGroup(auth.OrgID(c), group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -108,7 +126,7 @@ func getSecretGroup(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"group": group, "secrets": secrets})
}
// putSecretGroup upserts one or more keys into an existing (or new) group.
func putSecretGroup(c *gin.Context) {
group := c.Param("group")
if !validName(group) {
@@ -130,11 +148,11 @@ func putSecretGroup(c *gin.Context) {
return
}
}
if err := services.UpsertSecrets(group, values); err != nil {
if err := services.UpsertSecrets(auth.OrgID(c), group, values); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
services.LogEvent(auth.OrgID(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})
}
@@ -147,42 +165,42 @@ func revealSecret(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
value, err := services.RevealSecret(group, body.Key)
value, err := services.RevealSecret(auth.OrgID(c), group, body.Key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
services.LogEvent(auth.OrgID(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(group, key); err != nil {
if err := services.DeleteSecret(auth.OrgID(c), group, key); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
services.LogEvent(auth.OrgID(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(group); err != nil {
if err := services.DeleteSecretGroup(auth.OrgID(c), group); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
services.LogEvent(auth.OrgID(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()
token, err := services.RotateSecretsReadToken(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
services.LogEvent(auth.OrgID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
c.JSON(http.StatusOK, gin.H{"token": token})
}
+40 -40
View File
@@ -11,6 +11,7 @@ import (
"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"
)
@@ -80,7 +81,7 @@ func streamServerRunLog(c *gin.Context) {
sendNew := func() {
f, err := os.Open(path)
if err != nil {
return // file may not exist yet; keep waiting
return
}
defer f.Close()
if _, err := f.Seek(offset, 0); err != nil {
@@ -93,7 +94,7 @@ func streamServerRunLog(c *gin.Context) {
break
}
offset += int64(n)
// SSE data frame; split on newlines to keep frames well-formed.
for _, line := range splitSSE(buf[:n]) {
_, _ = c.Writer.WriteString("data: " + line + "\n")
}
@@ -105,10 +106,11 @@ func streamServerRunLog(c *gin.Context) {
ctx := c.Request.Context()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
orgID := auth.OrgID(c)
for {
sendNew()
if serverRunTerminal(runID, serverID) {
sendNew() // final drain
if serverRunTerminal(orgID, runID, serverID) {
sendNew()
_, _ = c.Writer.WriteString("event: done\ndata: end\n\n")
flusher.Flush()
return
@@ -121,9 +123,9 @@ func streamServerRunLog(c *gin.Context) {
}
}
// serverRunTerminal reports whether the given server-run has reached a terminal status.
func serverRunTerminal(runID, serverID string) bool {
r, err := services.GetRun(runID)
func serverRunTerminal(orgID, runID, serverID string) bool {
r, err := services.GetRun(orgID, runID)
if err != nil {
return true
}
@@ -139,15 +141,15 @@ func serverRunTerminal(runID, serverID string) bool {
return true
}
// splitSSE turns a raw byte slice into SSE-safe payload lines (newlines become
// separate data lines; carriage returns stripped).
func splitSSE(b []byte) []string {
s := strings.ReplaceAll(string(b), "\r", "")
return strings.Split(s, "\n")
}
func listSteps(c *gin.Context) {
steps, err := services.ListSteps()
steps, err := services.ListSteps(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -156,7 +158,7 @@ func listSteps(c *gin.Context) {
}
func stepUsage(c *gin.Context) {
counts, err := services.StepUsageCounts()
counts, err := services.StepUsageCounts(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -170,12 +172,12 @@ func createStep(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := services.CreateStep(s)
out, err := services.CreateStep(auth.OrgID(c), s)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name))
services.LogEvent(auth.OrgID(c), "workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name))
c.JSON(http.StatusCreated, out)
}
@@ -185,25 +187,25 @@ func updateStep(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.UpdateStep(c.Param("id"), s); err != nil {
if err := services.UpdateStep(auth.OrgID(c), c.Param("id"), s); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated")
services.LogEvent(auth.OrgID(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(c.Param("id")); err != nil {
if err := services.DeleteStep(auth.OrgID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted")
services.LogEvent(auth.OrgID(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(c.Param("id"))
b, err := services.ExportStep(auth.OrgID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -213,16 +215,16 @@ func exportStep(c *gin.Context) {
}
func seedDefaults(c *gin.Context) {
created, updated, err := services.SeedDefaultSteps()
created, updated, err := services.SeedDefaultSteps(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated))
services.LogEvent(auth.OrgID(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 // 1 MiB
const maxStepBodyBytes = 1 << 20
func importStep(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
@@ -231,17 +233,15 @@ func importStep(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := services.ImportStepToLibrary(body)
out, err := services.ImportStepToLibrary(auth.OrgID(c), body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name))
services.LogEvent(auth.OrgID(c), "workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name))
c.JSON(http.StatusCreated, out)
}
// parseStep validates a step doc and returns the normalized step WITHOUT
// persisting — used by the editor to insert an imported ad-hoc (inline) step.
func parseStep(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
body, err := io.ReadAll(c.Request.Body)
@@ -258,7 +258,7 @@ func parseStep(c *gin.Context) {
}
func listWorkflows(c *gin.Context) {
wfs, err := services.ListWorkflows()
wfs, err := services.ListWorkflows(auth.OrgID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -272,17 +272,17 @@ func createWorkflow(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := services.CreateWorkflow(w)
out, err := services.CreateWorkflow(auth.OrgID(c), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name))
services.LogEvent(auth.OrgID(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(c.Param("id"))
w, err := services.GetWorkflow(auth.OrgID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -296,12 +296,12 @@ func updateWorkflow(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.UpdateWorkflow(c.Param("id"), w); err != nil {
if err := services.UpdateWorkflow(auth.OrgID(c), c.Param("id"), w); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
updated, err := services.GetWorkflow(c.Param("id"))
services.LogEvent(auth.OrgID(c), "workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
updated, err := services.GetWorkflow(auth.OrgID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -310,21 +310,21 @@ func updateWorkflow(c *gin.Context) {
}
func deleteWorkflow(c *gin.Context) {
if err := services.DeleteWorkflow(c.Param("id")); err != nil {
if err := services.DeleteWorkflow(auth.OrgID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted")
services.LogEvent(auth.OrgID(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(c.Param("id"), actorFromCtx(c))
runID, err := services.TriggerWorkflow(auth.OrgID(c), c.Param("id"), actorFromCtx(c))
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID))
services.LogEvent(auth.OrgID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID))
c.JSON(http.StatusAccepted, gin.H{"run_id": runID})
}
@@ -335,7 +335,7 @@ func listWorkflowRuns(c *gin.Context) {
limit = n
}
}
runs, err := services.ListRuns(c.Param("id"), limit)
runs, err := services.ListRuns(auth.OrgID(c), c.Param("id"), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -344,7 +344,7 @@ func listWorkflowRuns(c *gin.Context) {
}
func getRun(c *gin.Context) {
r, err := services.GetRun(c.Param("runId"))
r, err := services.GetRun(auth.OrgID(c), c.Param("runId"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -353,10 +353,10 @@ func getRun(c *gin.Context) {
}
func cancelRun(c *gin.Context) {
if err := services.CancelRun(c.Param("runId")); err != nil {
if err := services.CancelRun(auth.OrgID(c), c.Param("runId")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent("workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
services.LogEvent(auth.OrgID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
c.JSON(http.StatusOK, gin.H{"cancelled": true})
}
+156
View File
@@ -0,0 +1,156 @@
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, OrgID: u.OrgID, 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
orgName string
)
if org, ok := OrgFromHost(c); ok {
n, err = services.CountOrgUsers(org.OrgID)
orgName = org.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, "org_name": orgName})
}
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 {
OrgName string `json:"org_name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.OrgName == "" || body.Email == "" || len(body.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "org_name, email, and password (>=8 chars) required"})
return
}
orgCount, err := services.CountOrgs()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var org *models.Org
switch orgCount {
case 0:
org, err = services.CreateOrg(body.OrgName)
case 1:
var existing *models.Org
existing, err = services.FirstOrg()
if err == nil {
org, err = services.AdoptOrg(existing.OrgID, body.OrgName)
}
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 org rather than through setup, "+
"or remove the unintended orgs and retry", orgCount)})
return
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, err := services.CreateUser(org.OrgID, 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, OrgID: u.OrgID, 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{"org": org, "slug": org.Slug})
}
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 hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID {
c.JSON(http.StatusForbidden, gin.H{"error": "org host mismatch"})
return
}
org, _ := services.GetOrg(sess.OrgID)
c.JSON(http.StatusOK, gin.H{"user": sess, "org": org})
}
+45 -5
View File
@@ -14,13 +14,42 @@ func GetSessionFromContext(c *gin.Context) *Session {
return sess
}
func OrgID(c *gin.Context) string {
if s := GetSessionFromContext(c); s != nil {
return s.OrgID
}
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.OrgID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session has no organization"})
return
}
c.Set(ctxSessionKey, sess)
if hostOrg, ok := OrgFromHost(c); ok && hostOrg.OrgID != sess.OrgID {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "org host mismatch"})
return
}
c.Next()
}
}
+90 -83
View File
@@ -2,123 +2,148 @@ 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(orgID string) {
provMu.Lock()
delete(provCache, orgID)
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 providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Provider, *oauth2.Config, error) {
cfg, err := services.GetOrgOIDC(orgID)
if err != nil || !cfg.Enabled {
return nil, nil, fmt.Errorf("org SSO not configured")
}
secret, err := services.GetOrgOIDCSecret(orgID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state generation failed"})
return nil, nil, err
}
provMu.Lock()
p := provCache[orgID]
provMu.Unlock()
if p == nil {
p, err = oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, nil, err
}
provMu.Lock()
provCache[orgID] = 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) {
org, ok := OrgFromHost(c)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown organization host"})
return
}
if err := SaveState(c.Request.Context(), state); err != nil {
ctx := c.Request.Context()
_, oauthCfg, err := providerForOrg(ctx, c, org.OrgID)
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 := SaveStateOrg(ctx, state, org.OrgID); 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")) {
orgID, ok := ConsumeStateOrg(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 := providerForOrg(ctx, c, orgID)
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(orgID, email, "", "member", "oidc")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
return
}
} else if u.OrgID != orgID {
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, OrgID: u.OrgID, 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 +159,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)
}
+80
View File
@@ -0,0 +1,80 @@
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 cachedOrg struct {
org *models.Org
at time.Time
}
var (
orgCacheMu sync.Mutex
orgCache = map[string]cachedOrg{}
)
const orgCacheTTL = 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 OrgFromHost(c *gin.Context) (*models.Org, bool) {
slug := hostSlug(c.Request.Host)
if slug == "" {
return nil, false
}
orgCacheMu.Lock()
if e, ok := orgCache[slug]; ok && time.Since(e.at) < orgCacheTTL {
orgCacheMu.Unlock()
return e.org, e.org != nil
}
orgCacheMu.Unlock()
org, err := services.GetOrgBySlug(slug)
if err != nil || org == nil {
return nil, false
}
orgCacheMu.Lock()
orgCache[slug] = cachedOrg{org: org, at: time.Now()}
orgCacheMu.Unlock()
return org, true
}
+10 -5
View File
@@ -17,6 +17,8 @@ const statePrefix = "km:state:"
type Session struct {
UserID string `json:"user_id"`
OrgID string `json:"org_id"`
Role string `json:"role"`
Email string `json:"email"`
Name string `json:"name"`
}
@@ -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 SaveStateOrg(ctx context.Context, state, orgID string) error {
return rdb.Set(ctx, statePrefix+state, orgID, 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 ConsumeStateOrg(ctx context.Context, state string) (string, bool) {
orgID, err := rdb.GetDel(ctx, statePrefix+state).Result()
if err != nil || orgID == "" {
return "", false
}
return orgID, true
}
+14 -14
View File
@@ -1,7 +1,3 @@
// Package checker runs service checks (http/tcp/icmp/tls) and returns a uniform
// Result. It has no dependency on models or pb so it can be duplicated verbatim
// into the agent module (agent-run monitors) — callers map their own monitor
// representation onto Spec.
package checker
import (
@@ -16,7 +12,7 @@ import (
"time"
)
// Check types (mirror models.Monitor* constants).
const (
TypeHTTP = "http"
TypeTCP = "tcp"
@@ -24,7 +20,7 @@ const (
TypeTLS = "tls"
)
// Spec is a self-contained description of a single check.
type Spec struct {
Type string
URL string
@@ -34,10 +30,11 @@ type Spec struct {
ExpectedStatus int
Keyword string
TLSWarnDays int
Insecure bool
TimeoutSec int
}
// Result is the uniform outcome of running a check.
type Result struct {
Up bool
LatencyMs int
@@ -53,7 +50,7 @@ func (s Spec) timeout() time.Duration {
return time.Duration(t) * time.Second
}
// Run executes the check described by s.
func Run(ctx context.Context, s Spec) Result {
switch s.Type {
case TypeHTTP:
@@ -79,6 +76,9 @@ func runHTTP(ctx context.Context, s Spec) Result {
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 {
@@ -156,9 +156,9 @@ func runTLS(ctx context.Context, s Spec) Result {
func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) }
// runICMP sends a single ICMP echo request and waits for the reply. Requires
// raw-socket privileges (the agent and server run as root). Returns down with a
// descriptive message when the socket cannot be opened or no reply arrives.
func runICMP(ctx context.Context, s Spec) Result {
dst, err := net.ResolveIPAddr("ip4", s.Host)
if err != nil {
@@ -188,18 +188,18 @@ func runICMP(ctx context.Context, s Spec) Result {
if err != nil {
return Result{LatencyMs: msSince(start), Message: "no reply"}
}
// Skip the IPv4 header (20 bytes) to reach the ICMP message.
if n < 28 || peer.String() != dst.String() {
continue
}
if reply[20] == 0 { // ICMP echo reply type
if reply[20] == 0 {
return Result{Up: true, LatencyMs: msSince(start)}
}
}
}
func icmpEcho(id, seq int) []byte {
// Type(8)=echo request, Code=0, Checksum, ID, Seq, no payload.
b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)}
cs := icmpChecksum(b)
b[2] = byte(cs >> 8)
+2 -2
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
)
// JSONCodec is a gRPC codec that uses JSON encoding.
type JSONCodec struct{}
func (JSONCodec) Marshal(v interface{}) ([]byte, error) {
@@ -16,5 +16,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"
}
+16 -15
View File
@@ -1,5 +1,5 @@
// Hand-written gRPC bindings for vantage.proto using JSON codec.
// To use: register the JSON codec before creating gRPC servers/clients.
package pb
@@ -11,7 +11,7 @@ import (
"google.golang.org/grpc/status"
)
// Message types
type RegisterRequest struct {
ServerId string `json:"server_id"`
@@ -47,7 +47,7 @@ type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
// CommandStream message types
type PackageUpdate struct {
Name string `json:"name"`
@@ -63,7 +63,7 @@ type ReportUpdatesRequest struct {
type ReportUpdatesResponse struct{}
// Inventory report message types
type CPUReport struct {
Model string `json:"model,omitempty"`
@@ -95,7 +95,7 @@ type InventoryReport struct {
}
type InventoryReportResponse struct{}
// Monitor sync / check report message types
type MonitorSpec struct {
MonitorId string `json:"monitor_id"`
@@ -107,6 +107,7 @@ type MonitorSpec struct {
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"`
}
@@ -143,8 +144,8 @@ type ServerCommand struct {
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
}
// CleanupWorkspaceCmd tells the agent to recursively remove the run's working
// directory once all steps on that server have finished.
type CleanupWorkspaceCmd struct {
WorkspaceId string `json:"workspace_id"`
}
@@ -188,8 +189,8 @@ type RunStepCmd struct {
Script string `json:"script"`
Env map[string]string `json:"env,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
// WorkspaceId names the per-run working directory the agent creates and uses
// as the step's cwd. Empty means run in the agent's default directory.
WorkspaceId string `json:"workspace_id,omitempty"`
}
@@ -208,7 +209,7 @@ type StepOutputChunk struct {
Eof bool `json:"eof,omitempty"`
}
// CommandStream server-side interface
type Vantage_CommandStreamServer interface {
Send(*ServerCommand) error
@@ -232,7 +233,7 @@ func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
return m, nil
}
// CommandStream client-side interface
type Vantage_CommandStreamClient interface {
Send(*AgentMessage) error
@@ -256,7 +257,7 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
return m, nil
}
// Server interface
type VantageServer interface {
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
@@ -303,7 +304,7 @@ func (UnimplementedVantageServer) CommandStream(Vantage_CommandStreamServer) err
return status.Errorf(codes.Unimplemented, "method CommandStream not implemented")
}
// Client interface
type VantageClient interface {
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
@@ -388,7 +389,7 @@ func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallO
return &vantageCommandStreamClient{stream}, nil
}
// Server registration
func RegisterVantageServer(s grpc.ServiceRegistrar, srv VantageServer) {
s.RegisterService(&Vantage_ServiceDesc, srv)
+16 -14
View File
@@ -62,14 +62,14 @@ func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKe
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
// Agent-generated keys carry no passphrase over the wire (proto has no field).
key, err := services.CreateKey(req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "")
key, err := services.CreateKey(srv.OrgID, 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.OrgID, key.KeyID, srv.ServerID); err != nil {
log.Printf("failed to auto-assign generated key: %v", err)
}
@@ -112,7 +112,7 @@ func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRe
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
monitors, err := services.ListMonitorsForRunner(srv.ServerID)
monitors, err := services.ListMonitorsForRunner(srv.OrgID, srv.ServerID)
if err != nil {
return nil, status.Errorf(codes.Internal, "list monitors")
}
@@ -128,6 +128,7 @@ func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRe
ExpectedStatus: m.Target.ExpectedStatus,
Keyword: m.Target.Keyword,
TLSWarnDays: m.Target.TLSWarnDays,
Insecure: m.Target.Insecure,
IntervalSec: m.IntervalSec,
Retries: m.Retries,
})
@@ -136,7 +137,8 @@ func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRe
}
func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRequest) (*pb.ReportChecksResponse, error) {
if _, err := services.ValidateAgentToken(req.ServerId, req.AgentToken); err != nil {
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 {
@@ -145,7 +147,7 @@ func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRe
t := time.Unix(r.CertExpiryUnix, 0)
res.CertExpiry = &t
}
if err := services.IngestResult(r.MonitorId, res); err != nil {
if err := services.IngestResult(srv.OrgID, srv.ServerID, r.MonitorId, res); err != nil {
log.Printf("ingest check %s: %v", r.MonitorId, err)
}
}
@@ -153,7 +155,7 @@ func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRe
}
func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) error {
// First message authenticates the agent and signals readiness.
msg, err := stream.Recv()
if err != nil {
return status.Errorf(codes.InvalidArgument, "expected initial auth message: %v", err)
@@ -174,8 +176,8 @@ func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) err
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()
@@ -222,15 +224,15 @@ func StartGRPC(port int) error {
}
s := grpc.NewServer(
// Accept client keepalive pings as fast as every 20s so the 30s agent
// ping interval is always within the allowed window.
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 20 * time.Second,
PermitWithoutStream: false,
}),
grpc.KeepaliveParams(keepalive.ServerParameters{
// Server also pings the client after 45s of inactivity so both
// sides can detect a dead connection without waiting for a timeout.
Time: 45 * time.Second,
Timeout: 10 * time.Second,
}),
+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"`
OrgID string `bson:"org_id" json:"org_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"`
}
+1
View File
@@ -8,6 +8,7 @@ import (
type AuditEvent struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
OrgID string `bson:"org_id" json:"org_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"`
+30
View File
@@ -0,0 +1,30 @@
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"`
OrgID string `bson:"org_id" json:"org_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"`
}
+4 -3
View File
@@ -8,17 +8,18 @@ import (
type ConsoleSession struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
OrgID string `bson:"org_id" json:"org_id"`
SessionID string `bson:"session_id" json:"session_id"`
ServerID string `bson:"server_id" json:"server_id"`
Protocol string `bson:"protocol" json:"protocol"` // ssh | rdp | vnc
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 marks the one-time session token as spent. Set atomically
// when the tunnel opens; a second open with the same token is rejected.
TokenConsumedAt *time.Time `bson:"token_consumed_at,omitempty" json:"-"`
SSHUsername string `bson:"ssh_username,omitempty" json:"ssh_username,omitempty"`
+2 -1
View File
@@ -8,11 +8,12 @@ import (
type Key struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
OrgID string `bson:"org_id" json:"org_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"`
+14 -10
View File
@@ -6,7 +6,7 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
)
// Monitor check types.
const (
MonitorHTTP = "http"
MonitorTCP = "tcp"
@@ -14,15 +14,15 @@ const (
MonitorTLS = "tls"
)
// Monitor status values.
const (
StatusUp = "up"
StatusDown = "down"
StatusPending = "pending"
)
// RunnerServer is the reserved Runner value for server-run monitors. Any other
// value is treated as a server_id whose agent runs the check locally.
const RunnerServer = "server"
type MonitorTarget struct {
@@ -33,27 +33,29 @@ type MonitorTarget struct {
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"` // up|down|pending
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"` // consecutive failures
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"`
OrgID string `bson:"org_id" json:"org_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"` // http|tcp|icmp|tls
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"` // "server" or a server_id
Retries int `bson:"retries" json:"retries"` // consecutive fails before down
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"`
@@ -61,6 +63,7 @@ type Monitor struct {
}
type Incident struct {
OrgID string `bson:"org_id" json:"org_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"`
@@ -69,8 +72,9 @@ type Incident struct {
}
type Rollup struct {
OrgID string `bson:"org_id" json:"org_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
PeriodStart time.Time `bson:"period_start" json:"period_start"` // hour bucket
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"`
+15
View File
@@ -0,0 +1,15 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Org struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
OrgID string `bson:"org_id" json:"org_id"`
Name string `bson:"name" json:"name"`
Slug string `bson:"slug" json:"slug"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+17
View File
@@ -0,0 +1,17 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type OrgOIDC struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
OrgID string `bson:"org_id" json:"org_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 -3
View File
@@ -6,17 +6,18 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
)
// Secret is a single key/value pair within a group. The value is stored
// encrypted (AES-256-GCM) and is never serialized to JSON.
type Secret struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
OrgID string `bson:"org_id" json:"org_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"`
}
// GroupSummary describes a group in the list view.
type GroupSummary struct {
Group string `json:"group"`
KeyCount int `json:"key_count"`
+19 -18
View File
@@ -44,23 +44,24 @@ type Inventory struct {
}
type Server struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
IPAddress string `bson:"ip_address" json:"ip_address"`
OSInfo string `bson:"os_info" json:"os_info"`
OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"`
ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"`
SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"`
RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"`
PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"`
PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"`
AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"`
Status string `bson:"status" json:"status"`
AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"`
LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"`
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
OrgID string `bson:"org_id" json:"org_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"`
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"`
}
+4 -3
View File
@@ -23,8 +23,8 @@ type EmailSettings struct {
UseTLS bool `bson:"use_tls" json:"use_tls"`
}
// SecretsSettings holds configuration for the secrets vault / ESO integration.
// The read token is stored as a SHA-256 hash and never returned to clients.
type SecretsSettings struct {
ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"`
ReadTokenSet bool `bson:"-" json:"read_token_set"`
@@ -33,9 +33,10 @@ type SecretsSettings struct {
type Settings struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
OrgID string `bson:"org_id" json:"org_id"`
Alerts AlertSettings `bson:"alerts" json:"alerts"`
Email EmailSettings `bson:"email" json:"email"`
Secrets SecretsSettings `bson:"secrets" json:"secrets"`
// WorkflowLogRetentionDays: nil = default 30, 0 = keep forever, N = N days.
WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"`
}
+35
View File
@@ -0,0 +1,35 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
)
func ValidRole(role string) bool {
switch role {
case RoleOwner, RoleAdmin, RoleMember:
return true
}
return false
}
type User struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
UserID string `bson:"user_id" json:"user_id"`
OrgID string `bson:"org_id" json:"org_id"`
Email string `bson:"email" json:"email"`
PasswordHash string `bson:"password_hash,omitempty" json:"-"`
Role string `bson:"role" json:"role"`
AuthSource string `bson:"auth_source" json:"auth_source"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
LastLogin *time.Time `bson:"last_login,omitempty" json:"last_login,omitempty"`
}
+14 -11
View File
@@ -14,15 +14,16 @@ type InputParam struct {
type WorkflowStep struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
OrgID string `bson:"org_id" json:"org_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"` // "bash" | "powershell"
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"` // "user" | "default"
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"`
@@ -32,7 +33,7 @@ type WorkflowStepRef struct {
StepID string `bson:"step_id,omitempty" json:"step_id,omitempty"`
Inline *WorkflowStep `bson:"inline,omitempty" json:"inline,omitempty"`
Order int `bson:"order" json:"order"`
OnFailure string `bson:"on_failure" json:"on_failure"` // "stop" | "continue" | "retry"
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"`
@@ -45,6 +46,7 @@ type StepOverride struct {
type Workflow struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
OrgID string `bson:"org_id" json:"org_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"`
@@ -53,12 +55,12 @@ type Workflow struct {
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
// ResolvedStep is a step frozen into a run snapshot (library step + overrides applied).
type ResolvedStep struct {
Order int `bson:"order" json:"order"`
Name string `bson:"name" json:"name"`
Interpreter string `bson:"interpreter" json:"interpreter"`
Script string `bson:"script" json:"script"`
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"`
@@ -68,7 +70,7 @@ type ResolvedStep struct {
type StepRun struct {
Order int `bson:"order" json:"order"`
Name string `bson:"name" json:"name"`
Status string `bson:"status" json:"status"` // queued|running|success|failed|skipped
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"`
@@ -80,7 +82,7 @@ type StepRun struct {
type ServerRun struct {
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
Status string `bson:"status" json:"status"` // queued|running|success|failed|skipped
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"`
@@ -89,11 +91,12 @@ type ServerRun struct {
type WorkflowRun struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
OrgID string `bson:"org_id" json:"org_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"` // running|success|failed|cancelled
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"`
+5 -12
View File
@@ -1,6 +1,3 @@
// Package monitorsched runs server-side monitors on their configured interval
// and funnels results through services.IngestResult. Agent-run monitors
// (runner != "server") are excluded — those execute on the agent.
package monitorsched
import (
@@ -14,8 +11,6 @@ import (
"github.com/mrhid6/vantage/server/internal/services"
)
// reloadInterval controls how often the scheduler re-reads monitor definitions
// so CRUD changes (new/removed/edited monitors) take effect.
const reloadInterval = 30 * time.Second
type runner struct {
@@ -24,8 +19,6 @@ type runner struct {
cancel context.CancelFunc
}
// Start launches the scheduler loop. It returns immediately; the loop runs until
// ctx is cancelled.
func Start(ctx context.Context) {
go loop(ctx)
}
@@ -35,7 +28,7 @@ func loop(ctx context.Context) {
var mu sync.Mutex
sync := func() {
monitors, err := services.ListMonitorsForRunner(models.RunnerServer)
monitors, err := services.ListServerScheduledMonitors()
if err != nil {
log.Printf("monitorsched: list monitors: %v", err)
return
@@ -47,7 +40,7 @@ func loop(ctx context.Context) {
mu.Lock()
defer mu.Unlock()
// Stop runners for monitors that vanished or changed interval.
for id, r := range active {
m, ok := want[id]
if !ok || m.IntervalSec != r.intervalSec {
@@ -55,7 +48,7 @@ func loop(ctx context.Context) {
delete(active, id)
}
}
// Start runners for new/changed monitors.
for id, m := range want {
if _, ok := active[id]; ok {
continue
@@ -88,12 +81,12 @@ func runMonitor(ctx context.Context, m models.Monitor) {
run := func() {
res := checker.Run(ctx, spec)
if err := services.IngestResult(m.MonitorID, res); err != nil {
if err := services.IngestServerScheduledResult(m.MonitorID, res); err != nil {
log.Printf("monitorsched: ingest %s: %v", m.MonitorID, err)
}
}
run() // check immediately on (re)start
run()
t := time.NewTicker(interval)
defer t.Stop()
for {
+64
View File
@@ -0,0 +1,64 @@
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(),
})
}
+71
View File
@@ -0,0 +1,71 @@
package notify
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/mrhid6/vantage/server/internal/models"
)
var httpClient = &http.Client{Timeout: 10 * time.Second}
func postJSON(target string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := httpClient.Post(target, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, target)
}
return nil
}
func dispatchWebhook(ch models.NotificationChannel, ev Event) error {
target := ch.Config["url"]
if target == "" {
return fmt.Errorf("webhook: missing url")
}
return postJSON(target, map[string]any{
"monitor": ev.MonitorName,
"type": ev.Type,
"old_status": ev.OldStatus,
"new_status": ev.NewStatus,
"message": ev.Message,
"time": ev.Time.Format(time.RFC3339),
})
}
func dispatchDiscord(ch models.NotificationChannel, ev Event) error {
target := ch.Config["url"]
if target == "" {
return fmt.Errorf("discord: missing url")
}
return postJSON(target, map[string]string{"content": ev.title()})
}
func dispatchSlack(ch models.NotificationChannel, ev Event) error {
target := ch.Config["url"]
if target == "" {
return fmt.Errorf("slack: missing url")
}
return postJSON(target, map[string]string{"text": ev.title()})
}
func dispatchTelegram(ch models.NotificationChannel, ev Event) error {
token := ch.Config["token"]
chatID := ch.Config["chat_id"]
if token == "" || chatID == "" {
return fmt.Errorf("telegram: missing token or chat_id")
}
api := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token)
return postJSON(api, map[string]string{"chat_id": chatID, "text": ev.title()})
}
+98
View File
@@ -0,0 +1,98 @@
package notify
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
"time"
"github.com/mrhid6/vantage/server/internal/models"
)
const smtpTimeout = 15 * time.Second
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()
}
+166
View File
@@ -0,0 +1,166 @@
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")
}
+4 -3
View File
@@ -11,11 +11,12 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func LogEvent(eventType, actor, serverID, keyID, details string) {
func LogEvent(orgID, eventType, actor, serverID, keyID, details string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
event := models.AuditEvent{
OrgID: orgID,
EventType: eventType,
Actor: actor,
ServerID: serverID,
@@ -28,7 +29,7 @@ func LogEvent(eventType, actor, serverID, keyID, details string) {
}
}
func ListAuditEvents(limit int64) ([]models.AuditEvent, error) {
func ListAuditEvents(orgID string, limit int64) ([]models.AuditEvent, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -36,7 +37,7 @@ func ListAuditEvents(limit int64) ([]models.AuditEvent, error) {
SetSort(bson.D{{Key: "created_at", Value: -1}}).
SetLimit(limit)
cursor, err := db.Col("audit_logs").Find(ctx, bson.M{}, opts)
cursor, err := db.Col("audit_logs").Find(ctx, bson.M{"org_id": orgID}, opts)
if err != nil {
return nil, err
}
+116
View File
@@ -0,0 +1,116 @@
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(orgID string) ([]models.NotificationChannel, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"org_id": orgID}, 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(orgID, 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, "org_id": orgID}).Decode(&ch)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
return &ch, nil
}
func GetChannels(orgID 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{"org_id": orgID, "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(orgID string, channelIDs []string) error {
for _, id := range channelIDs {
ch, err := GetChannel(orgID, id)
if err != nil {
return err
}
if ch == nil {
return errors.New("channel " + id + " not found")
}
}
return nil
}
func CreateChannel(orgID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) {
ctx, cancel := monCtx()
defer cancel()
ch.OrgID = orgID
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(orgID, channelID string, upd bson.M) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}, bson.M{"$set": upd})
return err
}
func DeleteChannel(orgID, channelID string) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID})
return err
}
func TestChannel(orgID, channelID string) error {
ch, err := GetChannel(orgID, channelID)
if err != nil {
return err
}
if ch == nil {
return errors.New("channel not found")
}
return notify.Test(*ch)
}
+31 -30
View File
@@ -17,7 +17,7 @@ import (
)
func sessionHMACKey() ([]byte, error) {
// Reuse the AES key material as the HMAC secret. Distinct domain via prefix.
k, err := encryptionKey()
if err != nil {
return nil, err
@@ -29,7 +29,7 @@ func sessionHMACKey() ([]byte, error) {
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
// SignSessionToken returns a signed, expiring token binding a session id.
func SignSessionToken(sessionID string, ttl time.Duration) (string, error) {
key, err := sessionHMACKey()
if err != nil {
@@ -42,7 +42,7 @@ func SignSessionToken(sessionID string, ttl time.Duration) (string, error) {
return payload + "." + b64(mac.Sum(nil)), nil
}
// VerifySessionToken checks signature + expiry and returns the session id.
func VerifySessionToken(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
@@ -86,10 +86,10 @@ func portOr(v, def int) string {
return strconv.Itoa(v)
}
// BuildGuacParams assembles the guacd connection parameter map for a protocol.
// privateKey/passphrase are the decrypted SSH private key and its optional
// passphrase (ssh only); rdpUser/rdpPass are used for rdp, and rdpPass carries
// the password for vnc. None of these values are persisted or logged by the caller.
func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass string) (*GuacParams, error) {
host := srv.IPAddress
switch protocol {
@@ -129,11 +129,12 @@ func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphra
}
}
func CreateConsoleSession(serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) {
func CreateConsoleSession(orgID, serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s := &models.ConsoleSession{
OrgID: orgID,
SessionID: uuid.NewString(),
ServerID: serverID,
Protocol: protocol,
@@ -148,19 +149,19 @@ func CreateConsoleSession(serverID, protocol, keyID, user, clientIP string) (*mo
return s, nil
}
func GetConsoleSession(sessionID string) (*models.ConsoleSession, error) {
func GetConsoleSession(orgID, sessionID string) (*models.ConsoleSession, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.ConsoleSession
if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID}).Decode(&s); err != nil {
if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID, "org_id": orgID}).Decode(&s); err != nil {
return nil, err
}
return &s, nil
}
// StashConsoleRDPCreds encrypts and stores single-use RDP credentials on the
// session document. They are consumed (and cleared) when the tunnel opens.
func StashConsoleRDPCreds(sessionID, username, password string) error {
func StashConsoleRDPCreds(orgID, sessionID, username, password string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
u, err := encryptString(username)
@@ -172,17 +173,17 @@ func StashConsoleRDPCreds(sessionID, username, password string) error {
return err
}
_, err = db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID},
bson.M{"session_id": sessionID, "org_id": orgID},
bson.M{"$set": bson.M{"rdp_user_enc": u, "rdp_pass_enc": p}},
)
return err
}
// ConsumeConsoleRDPCreds decrypts and returns the stored RDP credentials, then
// clears them from the session document (single-use). Returns empty strings if
// none were stored.
func ConsumeConsoleRDPCreds(sessionID string) (username, password string, err error) {
s, err := GetConsoleSession(sessionID)
func ConsumeConsoleRDPCreds(orgID, sessionID string) (username, password string, err error) {
s, err := GetConsoleSession(orgID, sessionID)
if err != nil {
return "", "", err
}
@@ -202,31 +203,31 @@ func ConsumeConsoleRDPCreds(sessionID string) (username, password string, err er
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _ = db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID},
bson.M{"session_id": sessionID, "org_id": orgID},
bson.M{"$unset": bson.M{"rdp_user_enc": "", "rdp_pass_enc": ""}},
)
return username, password, nil
}
// SetConsoleSSHUser persists the SSH username to use on the session doc.
func SetConsoleSSHUser(sessionID, username string) error {
func SetConsoleSSHUser(orgID, sessionID, username string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID},
bson.M{"session_id": sessionID, "org_id": orgID},
bson.M{"$set": bson.M{"ssh_username": username}})
return err
}
// ConsumeSessionToken atomically marks a session's one-time token as spent.
// It returns an error if the token was already consumed (replay) or the session
// does not exist, so the tunnel can be opened at most once per issued token.
func ConsumeSessionToken(sessionID string) error {
func ConsumeSessionToken(orgID, sessionID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
res, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID, "token_consumed_at": nil},
bson.M{"session_id": sessionID, "org_id": orgID, "token_consumed_at": nil},
bson.M{"$set": bson.M{"token_consumed_at": now}},
)
if err != nil {
@@ -238,12 +239,12 @@ func ConsumeSessionToken(sessionID string) error {
return nil
}
func EndConsoleSession(sessionID string) error {
func EndConsoleSession(orgID, sessionID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
_, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID, "ended_at": nil},
bson.M{"session_id": sessionID, "org_id": orgID, "ended_at": nil},
bson.M{"$set": bson.M{"ended_at": now}},
)
return err
-111
View File
@@ -1,111 +0,0 @@
package services
import (
"testing"
"time"
"github.com/mrhid6/vantage/server/internal/models"
)
func TestSessionTokenRoundTrip(t *testing.T) {
t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
tok, err := SignSessionToken("sess-123", time.Minute)
if err != nil {
t.Fatalf("sign: %v", err)
}
got, err := VerifySessionToken(tok)
if err != nil {
t.Fatalf("verify: %v", err)
}
if got != "sess-123" {
t.Fatalf("got %q want sess-123", got)
}
}
func TestSessionTokenExpired(t *testing.T) {
t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
tok, err := SignSessionToken("sess-123", -time.Second)
if err != nil {
t.Fatalf("sign: %v", err)
}
if _, err := VerifySessionToken(tok); err == nil {
t.Fatalf("expected expiry error, got nil")
}
}
func TestSessionTokenTampered(t *testing.T) {
t.Setenv("KEY_ENCRYPTION_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
tok, _ := SignSessionToken("sess-123", time.Minute)
if _, err := VerifySessionToken(tok + "x"); err == nil {
t.Fatalf("expected signature error, got nil")
}
}
func TestBuildGuacParamsSSH(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22}
p, err := BuildGuacParams(srv, "ssh", "", "PRIVATE-KEY-DATA", "", "", "")
if err != nil {
t.Fatalf("err: %v", err)
}
if p.Protocol != "ssh" {
t.Fatalf("protocol %q", p.Protocol)
}
if p.Params["hostname"] != "10.0.0.5" || p.Params["port"] != "22" {
t.Fatalf("bad host/port: %+v", p.Params)
}
if p.Params["private-key"] != "PRIVATE-KEY-DATA" {
t.Fatalf("missing private-key")
}
if p.Params["username"] != "root" {
t.Fatalf("expected default username root, got %q", p.Params["username"])
}
}
func TestBuildGuacParamsRDP(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.9", RDPPort: 3389}
p, err := BuildGuacParams(srv, "rdp", "", "", "", "administrator", "s3cret")
if err != nil {
t.Fatalf("err: %v", err)
}
if p.Params["port"] != "3389" || p.Params["username"] != "administrator" || p.Params["password"] != "s3cret" {
t.Fatalf("bad rdp params: %+v", p.Params)
}
if p.Params["ignore-cert"] != "true" {
t.Fatalf("expected ignore-cert=true")
}
}
func TestBuildGuacParamsUnknownProtocol(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.9"}
if _, err := BuildGuacParams(srv, "telnet", "", "", "", "", ""); err == nil {
t.Fatalf("expected error for unknown protocol")
}
}
func TestBuildGuacParamsSSHPassphrase(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.5", SSHPort: 22}
p, err := BuildGuacParams(srv, "ssh", "deploy", "PK", "s3cret-phrase", "", "")
if err != nil {
t.Fatalf("err: %v", err)
}
if p.Params["username"] != "deploy" {
t.Fatalf("username %q", p.Params["username"])
}
if p.Params["passphrase"] != "s3cret-phrase" {
t.Fatalf("missing passphrase: %+v", p.Params)
}
}
func TestBuildGuacParamsVNC(t *testing.T) {
srv := &models.Server{IPAddress: "10.0.0.7"}
p, err := BuildGuacParams(srv, "vnc", "", "", "", "", "vncpass")
if err != nil {
t.Fatalf("err: %v", err)
}
if p.Protocol != "vnc" || p.Params["hostname"] != "10.0.0.7" || p.Params["port"] != "5900" || p.Params["password"] != "vncpass" {
t.Fatalf("bad vnc params: %+v", p.Params)
}
}
+3 -3
View File
@@ -22,8 +22,8 @@ func encryptionKey() ([]byte, error) {
return key, nil
}
// encryptString encrypts a plaintext value with AES-256-GCM using the
// shared KEY_ENCRYPTION_KEY, returning hex(nonce + ciphertext).
func encryptString(plaintext string) (string, error) {
key, err := encryptionKey()
if err != nil {
@@ -45,7 +45,7 @@ func encryptString(plaintext string) (string, error) {
return hex.EncodeToString(sealed), nil
}
// decryptString reverses encryptString.
func decryptString(ciphertextHex string) (string, error) {
key, err := encryptionKey()
if err != nil {
+9 -8
View File
@@ -12,7 +12,7 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// DefaultStepsDir returns the directory holding default step JSON files.
func DefaultStepsDir() string {
dir := os.Getenv("VANTAGE_DEFAULT_STEPS_DIR")
if dir == "" {
@@ -22,9 +22,9 @@ func DefaultStepsDir() string {
return dir
}
// readDefaultStepFiles parses every *.json in the defaults dir into
// source=default library steps (with slug set). Non-json and invalid files are
// skipped silently; a slug is derived from the step name.
func readDefaultStepFiles() ([]models.WorkflowStep, error) {
matches, err := filepath.Glob(filepath.Join(DefaultStepsDir(), "*.json"))
if err != nil {
@@ -50,9 +50,9 @@ func readDefaultStepFiles() ([]models.WorkflowStep, error) {
return out, nil
}
// SeedDefaultSteps upserts default steps from disk keyed on {slug, source}.
// Re-sync overwrites default-step content; user steps are never touched.
func SeedDefaultSteps() (created, updated int, err error) {
func SeedDefaultSteps(orgID string) (created, updated int, err error) {
steps, err := readDefaultStepFiles()
if err != nil {
return 0, 0, err
@@ -61,7 +61,7 @@ func SeedDefaultSteps() (created, updated int, err error) {
defer cancel()
col := db.Col("workflow_steps")
for _, s := range steps {
filter := bson.M{"slug": s.Slug, "source": "default"}
filter := bson.M{"org_id": orgID, "slug": s.Slug, "source": "default"}
set := bson.M{
"name": s.Name,
"description": s.Description,
@@ -75,6 +75,7 @@ func SeedDefaultSteps() (created, updated int, err error) {
res, uerr := col.UpdateOne(ctx, filter, bson.M{
"$set": set,
"$setOnInsert": bson.M{
"org_id": orgID,
"step_id": uuid.New().String(),
"slug": s.Slug,
"source": "default",
-38
View File
@@ -1,38 +0,0 @@
package services
import (
"os"
"path/filepath"
"testing"
)
func TestDefaultStepsDirEnv(t *testing.T) {
dir := filepath.Join(t.TempDir(), "ds")
t.Setenv("VANTAGE_DEFAULT_STEPS_DIR", dir)
got := DefaultStepsDir()
if got != dir {
t.Fatalf("got %q want %q", got, dir)
}
if _, err := os.Stat(dir); err != nil {
t.Fatalf("dir not created: %v", err)
}
}
func TestReadDefaultStepFiles(t *testing.T) {
dir := t.TempDir()
t.Setenv("VANTAGE_DEFAULT_STEPS_DIR", dir)
good := `{"kind":"vantage.step/v1","name":"Ping Host","interpreter":"bash","script":"ping -c1 x=1 >> $WORKFLOW_ENV"}`
os.WriteFile(filepath.Join(dir, "ping.json"), []byte(good), 0600)
os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignore me"), 0600)
steps, err := readDefaultStepFiles()
if err != nil {
t.Fatal(err)
}
if len(steps) != 1 {
t.Fatalf("want 1 step, got %d", len(steps))
}
if steps[0].Slug != "ping-host" || steps[0].Source != "default" {
t.Fatalf("bad seed step: %+v", steps[0])
}
}
+22 -22
View File
@@ -17,13 +17,13 @@ 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 +32,14 @@ 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,15 +62,15 @@ func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) err
}
}
// DispatchRunStep pushes a RunStepCmd to a server's agent. Caller must have
// registered StepResults.Await(commandID) first.
func DispatchRunStep(serverID, commandID string, cmd *pb.RunStepCmd) error {
return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, RunStep: cmd})
}
// DispatchCleanupWorkspace tells a server's agent to remove a run's working
// directory. Best-effort and fire-and-forget: if the agent is gone the temp dir
// is reclaimed by the OS on reboot anyway.
func DispatchCleanupWorkspace(serverID, workspaceID string) {
if !Dispatcher.IsConnected(serverID) {
return
@@ -81,7 +81,7 @@ func DispatchCleanupWorkspace(serverID, workspaceID string) {
})
}
// KeyGenParams carries all options for a generate-key command.
type KeyGenParams struct {
Label string
KeyType string
@@ -90,15 +90,15 @@ 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/vantage/releases?limit=20", giteaHost)
resp, err := http.Get(url) //nolint:gosec
resp, err := http.Get(url)
if err != nil {
return "", fmt.Errorf("fetch releases: %w", err)
}
@@ -122,8 +122,8 @@ 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")
@@ -153,7 +153,7 @@ func DispatchUpdateAgent(serverID string) (string, error) {
return version, nil
}
// DispatchApplyUpdates sends an apply-updates command to the named server's agent.
func DispatchApplyUpdates(serverID string) error {
if !Dispatcher.IsConnected(serverID) {
return fmt.Errorf("agent is not connected to the command stream")
@@ -165,8 +165,8 @@ func DispatchApplyUpdates(serverID string) error {
return Dispatcher.dispatch(serverID, cmd)
}
// DispatchDeleteKey sends a delete-key command to the named server's agent.
// It is best-effort: if the agent is offline the local files will remain until next connection.
func DispatchDeleteKey(serverID, label string) {
if !Dispatcher.IsConnected(serverID) {
return
@@ -176,13 +176,13 @@ 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")
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
)
// StoreInventory upserts the latest inventory snapshot onto the server document.
// Metrics fields update every call; static fields only when r.IncludeStatic.
func StoreInventory(serverID string, r *pb.InventoryReport) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+34 -25
View File
@@ -36,8 +36,9 @@ func setKeyMeta(k *models.Key) {
k.HasPassphrase = k.PassphraseEncrypted != ""
}
func CreateKey(label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
func CreateKey(orgID, label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
key := &models.Key{
OrgID: orgID,
KeyID: uuid.NewString(),
Label: label,
PublicKey: publicKey,
@@ -71,12 +72,12 @@ func CreateKey(label, publicKey, source, generatedByServerID, privateKey, passph
return key, nil
}
func GetKey(keyID string) (*models.Key, error) {
func GetKey(orgID, 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, "org_id": orgID}).Decode(&key)
if err != nil {
return nil, err
}
@@ -84,12 +85,12 @@ func GetKey(keyID string) (*models.Key, error) {
return &key, nil
}
func GetPrivateKey(keyID string) (string, error) {
func GetPrivateKey(orgID, 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, "org_id": orgID}).Decode(&key); err != nil {
return "", err
}
if key.PrivateKeyEncrypted == "" {
@@ -98,8 +99,6 @@ func GetPrivateKey(keyID string) (string, error) {
return decryptPrivateKey(key.PrivateKeyEncrypted)
}
// GetPassphrase returns the decrypted passphrase for a key, or an empty string
// if the key has none stored.
func GetPassphrase(keyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -119,11 +118,11 @@ type KeyWithCount struct {
AssignedCount int `bson:"-" json:"assigned_count"`
}
func ListKeys() ([]KeyWithCount, error) {
func ListKeys(orgID 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{"org_id": orgID})
if err != nil {
return nil, err
}
@@ -138,6 +137,7 @@ func ListKeys() ([]KeyWithCount, error) {
for _, k := range keys {
setKeyMeta(&k)
count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{
"org_id": orgID,
"key_id": k.KeyID,
"revoked_at": nil,
})
@@ -146,19 +146,19 @@ func ListKeys() ([]KeyWithCount, error) {
return result, nil
}
func DeleteKey(keyID string) error {
func DeleteKey(orgID, 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, "org_id": orgID}).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, "org_id": orgID}); 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, "org_id": orgID}); err != nil {
return err
}
@@ -168,13 +168,21 @@ func DeleteKey(keyID string) error {
return nil
}
func AssignKey(keyID, serverID string) (*models.Assignment, error) {
func AssignKey(orgID, 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(orgID, keyID); err != nil {
return nil, fmt.Errorf("key not found")
}
if _, err := GetServer(orgID, serverID); err != nil {
return nil, fmt.Errorf("server not found")
}
var existing models.Assignment
err := db.Col("assignments").FindOne(ctx, bson.M{
"org_id": orgID,
"key_id": keyID,
"server_id": serverID,
"revoked_at": nil,
@@ -184,6 +192,7 @@ func AssignKey(keyID, serverID string) (*models.Assignment, error) {
}
a := &models.Assignment{
OrgID: orgID,
KeyID: keyID,
ServerID: serverID,
AssignedAt: time.Now(),
@@ -195,23 +204,23 @@ func AssignKey(keyID, serverID string) (*models.Assignment, error) {
return a, nil
}
func RevokeAssignment(keyID, serverID string) error {
func RevokeAssignment(orgID, 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{"org_id": orgID, "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(orgID, 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{"org_id": orgID, "key_id": keyID, "revoked_at": nil})
if err != nil {
return nil, err
}
@@ -229,11 +238,11 @@ type AssignmentWithServer struct {
Server *models.Server `json:"server,omitempty"`
}
func GetAssignmentsWithServers(keyID string) ([]AssignmentWithServer, error) {
func GetAssignmentsWithServers(orgID, 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{"org_id": orgID, "key_id": keyID})
if err != nil {
return nil, err
}
@@ -248,7 +257,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, "org_id": orgID}).Decode(&srv); err == nil {
item.Server = &srv
}
result = append(result, item)
@@ -261,11 +270,11 @@ type AssignmentWithKey struct {
Key *models.Key `json:"key,omitempty"`
}
func GetAssignmentsWithKeysForServer(serverID string) ([]AssignmentWithKey, error) {
func GetAssignmentsWithKeysForServer(orgID, 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{"org_id": orgID, "server_id": serverID})
if err != nil {
return nil, err
}
@@ -279,7 +288,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, "org_id": orgID}).Decode(&key); err != nil {
continue
}
setKeyMeta(&key)
+227
View File
@@ -0,0 +1,227 @@
package services
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
var scopedCollections = []string{
"servers", "keys", "assignments", "secrets",
"workflows", "workflow_steps", "workflow_runs",
"audit_logs", "monitors", "notification_channels",
"console_sessions", "incidents", "monitor_rollups",
}
func EnsureAuthIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := db.Col("users").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "email", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
if _, err := db.Col("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "slug", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
if _, err := db.Col("org_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "org_id", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
return nil
}
func defaultBackfillOrg(ctx context.Context) (*models.Org, error) {
var org models.Org
err := db.Col("orgs").FindOne(ctx, bson.M{"slug": "default"}).Decode(&org)
switch {
case err == nil:
case errors.Is(err, mongo.ErrNoDocuments):
org = models.Org{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 scopedCollections {
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 scopedCollections {
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 models.Org
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 = models.Org{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
}
+163 -34
View File
@@ -3,12 +3,15 @@ package services
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/checker"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/server/internal/notify"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
@@ -18,7 +21,7 @@ func monCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
// SpecFor maps a monitor onto a checker.Spec.
func SpecFor(m *models.Monitor) checker.Spec {
return checker.Spec{
Type: m.Type,
@@ -29,14 +32,15 @@ func SpecFor(m *models.Monitor) checker.Spec {
ExpectedStatus: m.Target.ExpectedStatus,
Keyword: m.Target.Keyword,
TLSWarnDays: m.Target.TLSWarnDays,
Insecure: m.Target.Insecure,
TimeoutSec: m.IntervalSec,
}
}
func ListMonitors() ([]models.Monitor, error) {
func ListMonitors(orgID string) ([]models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("monitors").Find(ctx, bson.M{}, options.Find().SetSort(bson.M{"created_at": 1}))
cur, err := db.Col("monitors").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.M{"created_at": 1}))
if err != nil {
return nil, err
}
@@ -47,11 +51,25 @@ func ListMonitors() ([]models.Monitor, error) {
return out, nil
}
// ListMonitorsForRunner returns enabled monitors whose Runner matches runner.
func ListMonitorsForRunner(runner string) ([]models.Monitor, error) {
func ListMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) {
if orgID == "" {
return nil, errors.New("org id required")
}
return listMonitorsForRunner(orgID, runner)
}
func ListServerScheduledMonitors() ([]models.Monitor, error) {
return listMonitorsForRunner("", models.RunnerServer)
}
func listMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("monitors").Find(ctx, bson.M{"runner": runner, "enabled": true})
filter := bson.M{"runner": runner, "enabled": true}
if orgID != "" {
filter["org_id"] = orgID
}
cur, err := db.Col("monitors").Find(ctx, filter)
if err != nil {
return nil, err
}
@@ -62,7 +80,22 @@ func ListMonitorsForRunner(runner string) ([]models.Monitor, error) {
return out, nil
}
func GetMonitor(monitorID string) (*models.Monitor, error) {
func GetMonitor(orgID, monitorID string) (*models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
var m models.Monitor
err := db.Col("monitors").FindOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}).Decode(&m)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
func getMonitorByID(monitorID string) (*models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
var m models.Monitor
@@ -76,9 +109,26 @@ func GetMonitor(monitorID string) (*models.Monitor, error) {
return &m, nil
}
func CreateMonitor(m *models.Monitor) (*models.Monitor, error) {
func validateRunner(orgID, runner string) error {
if runner == "" || runner == models.RunnerServer {
return nil
}
if _, err := GetServer(orgID, runner); err != nil {
return fmt.Errorf("runner server %s not found", runner)
}
return nil
}
func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
if err := validateChannelIDs(orgID, m.ChannelIDs); err != nil {
return nil, err
}
if err := validateRunner(orgID, m.Runner); err != nil {
return nil, err
}
m.OrgID = orgID
m.MonitorID = uuid.NewString()
m.CreatedAt = time.Now()
if m.IntervalSec <= 0 {
@@ -97,31 +147,62 @@ func CreateMonitor(m *models.Monitor) (*models.Monitor, error) {
return m, nil
}
func UpdateMonitor(monitorID string, upd bson.M) error {
func UpdateMonitor(orgID, monitorID string, upd bson.M) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID}, bson.M{"$set": upd})
if raw, present := upd["channel_ids"]; present {
ids, ok := raw.([]string)
if !ok {
return fmt.Errorf("channel_ids must be a string array")
}
if err := validateChannelIDs(orgID, ids); err != nil {
return err
}
}
if raw, present := upd["runner"]; present {
runner, ok := raw.(string)
if !ok {
return fmt.Errorf("runner must be a string")
}
if err := validateRunner(orgID, runner); err != nil {
return err
}
if runner == "" {
upd["runner"] = models.RunnerServer
}
}
_, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}, bson.M{"$set": upd})
return err
}
func DeleteMonitor(monitorID string) error {
func DeleteMonitor(orgID, monitorID string) error {
ctx, cancel := monCtx()
defer cancel()
if _, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID}); err != nil {
res, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID})
if err != nil {
return err
}
db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID})
db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID})
if res.DeletedCount == 0 {
return nil
}
db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID})
db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID})
return nil
}
func ListIncidents(monitorID string, limit int64) ([]models.Incident, error) {
func ListIncidents(orgID, monitorID string, limit int64) ([]models.Incident, error) {
ctx, cancel := monCtx()
defer cancel()
if limit <= 0 {
limit = 50
}
cur, err := db.Col("incidents").Find(ctx, bson.M{"monitor_id": monitorID},
cur, err := db.Col("incidents").Find(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID},
options.Find().SetSort(bson.M{"started_at": -1}).SetLimit(limit))
if err != nil {
return nil, err
@@ -133,12 +214,12 @@ func ListIncidents(monitorID string, limit int64) ([]models.Incident, error) {
return out, nil
}
// UptimeRollups returns hourly rollups for a monitor since the cutoff, oldest first.
func UptimeRollups(monitorID string, since time.Time) ([]models.Rollup, error) {
func UptimeRollups(orgID, monitorID string, since time.Time) ([]models.Rollup, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("monitor_rollups").Find(ctx,
bson.M{"monitor_id": monitorID, "period_start": bson.M{"$gte": since}},
bson.M{"monitor_id": monitorID, "org_id": orgID, "period_start": bson.M{"$gte": since}},
options.Find().SetSort(bson.M{"period_start": 1}))
if err != nil {
return nil, err
@@ -150,18 +231,34 @@ func UptimeRollups(monitorID string, since time.Time) ([]models.Rollup, error) {
return out, nil
}
// IngestResult applies a check result to a monitor: updates state, opens/resolves
// incidents on up<->down transitions, rolls up the hourly bucket, and fires
// notifications on transition. Both the server scheduler and agent-reported
// results funnel through here.
func IngestResult(monitorID string, res checker.Result) error {
func IngestResult(orgID, runner, monitorID string, res checker.Result) error {
if orgID == "" {
return errors.New("org id required")
}
return ingestResult(orgID, runner, monitorID, res)
}
func IngestServerScheduledResult(monitorID string, res checker.Result) error {
return ingestResult("", models.RunnerServer, monitorID, res)
}
func ingestResult(orgID, runner, monitorID string, res checker.Result) error {
ctx, cancel := monCtx()
defer cancel()
m, err := GetMonitor(monitorID)
if err != nil || m == nil {
m, err := getMonitorByID(monitorID)
if err != nil {
return err
}
if m == nil {
return fmt.Errorf("monitor %s not found", monitorID)
}
if orgID != "" && m.OrgID != orgID {
return fmt.Errorf("monitor %s belongs to another org", monitorID)
}
if m.Runner != runner {
return fmt.Errorf("monitor %s is not run by %s", monitorID, runner)
}
now := time.Now()
prev := m.State.Status
@@ -198,22 +295,28 @@ func IngestResult(monitorID string, res checker.Result) error {
return err
}
// Hourly rollup.
bucket := now.Truncate(time.Hour)
up := 0
if res.Up {
up = 1
}
db.Col("monitor_rollups").UpdateOne(ctx,
bson.M{"monitor_id": monitorID, "period_start": bucket},
bson.M{"$inc": bson.M{"checks": 1, "up_count": up, "sum_latency": int64(res.LatencyMs)}},
bson.M{
"$inc": bson.M{"checks": 1, "up_count": up, "sum_latency": int64(res.LatencyMs)},
"$setOnInsert": bson.M{"org_id": m.OrgID},
},
options.UpdateOne().SetUpsert(true))
// Transition handling.
if newStatus != prev {
switch newStatus {
case models.StatusDown:
inc := models.Incident{
OrgID: m.OrgID,
IncidentID: uuid.NewString(),
MonitorID: monitorID,
StartedAt: now,
@@ -224,7 +327,7 @@ func IngestResult(monitorID string, res checker.Result) error {
case models.StatusUp:
if prev == models.StatusDown {
db.Col("incidents").UpdateOne(ctx,
bson.M{"monitor_id": monitorID, "resolved_at": nil},
bson.M{"monitor_id": monitorID, "org_id": m.OrgID, "resolved_at": nil},
bson.M{"$set": bson.M{"resolved_at": now}})
notifyTransition(m, newStatus, res.Message)
}
@@ -233,9 +336,35 @@ func IngestResult(monitorID string, res checker.Result) error {
return nil
}
// notifyTransition dispatches notifications on an up<->down transition. Wired up
// in Task 13 (P3); a no-op until then.
func notifyTransition(m *models.Monitor, newStatus, message string) {
// TODO(P3): resolve m.ChannelIDs and dispatch via the notify package,
// honouring a resend interval tracked on state.last_notified_at.
if len(m.ChannelIDs) == 0 {
return
}
channels, err := GetChannels(m.OrgID, m.ChannelIDs)
if err != nil {
log.Printf("notify: load channels for %s: %v", m.MonitorID, err)
return
}
ev := notify.Event{
MonitorName: m.Name,
Type: m.Type,
OldStatus: m.State.Status,
NewStatus: newStatus,
Message: message,
Time: time.Now(),
}
for _, ch := range channels {
if !ch.Enabled {
continue
}
go func(c models.NotificationChannel) {
if err := notify.Dispatch(c, ev); err != nil {
log.Printf("notify: dispatch to %s (%s): %v", c.Name, c.Type, err)
}
}(ch)
}
_ = UpdateMonitor(m.OrgID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()})
}
+52
View File
@@ -0,0 +1,52 @@
package services
import (
"context"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func GetOrgOIDC(orgID string) (*models.OrgOIDC, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.OrgOIDC
err := db.Col("org_oidc").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o)
if err != nil {
return nil, err
}
return &o, nil
}
func GetOrgOIDCSecret(orgID string) (string, error) {
o, err := GetOrgOIDC(orgID)
if err != nil {
return "", err
}
return decryptString(o.ClientSecretEnc)
}
func SaveOrgOIDC(orgID, issuer, clientID, clientSecret string, enabled bool) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
set := bson.M{
"org_id": orgID, "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("org_oidc").UpdateOne(ctx,
bson.M{"org_id": orgID}, bson.M{"$set": set},
options.UpdateOne().SetUpsert(true))
return err
}
+157
View File
@@ -0,0 +1,157 @@
package services
import (
"context"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
var reservedSlugs = map[string]bool{
"www": true, "api": true, "app": true, "admin": true, "auth": true,
"install": true, "static": true, "_next": true, "default": true,
}
func GetOrg(orgID string) (*models.Org, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.Org
err := db.Col("orgs").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&o)
if err != nil {
return nil, err
}
return &o, nil
}
func GetOrgBySlug(slug string) (*models.Org, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.Org
err := db.Col("orgs").FindOne(ctx, bson.M{"slug": slug}).Decode(&o)
if err != nil {
return nil, err
}
return &o, nil
}
func ListOrgIDs() ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("orgs").Find(ctx, bson.M{})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var orgs []models.Org
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.OrgID)
}
return ids, nil
}
func CountOrgs() (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return db.Col("orgs").CountDocuments(ctx, bson.M{})
}
func FirstOrg() (*models.Org, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.Org
if err := db.Col("orgs").FindOne(ctx, bson.M{}).Decode(&o); err != nil {
return nil, err
}
return &o, nil
}
func AdoptOrg(orgID, name string) (*models.Org, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
set := bson.M{"name": name}
slug := Slugify(name)
if len(slug) > 40 {
slug = slug[:40]
}
if len(slug) >= 3 && !reservedSlugs[slug] {
n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug, "org_id": bson.M{"$ne": orgID}})
if err != nil {
return nil, err
}
if n == 0 {
set["slug"] = slug
}
}
if _, err := db.Col("orgs").UpdateOne(ctx, bson.M{"org_id": orgID}, bson.M{"$set": set}); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, fmt.Errorf("organization slug already taken")
}
return nil, err
}
return GetOrg(orgID)
}
func CreateOrg(name string) (*models.Org, error) {
base := Slugify(name)
if len(base) < 3 {
return nil, fmt.Errorf("organization name too short (slug must be >= 3 chars)")
}
if len(base) > 40 {
base = base[:40]
}
if reservedSlugs[base] {
return nil, fmt.Errorf("organization name is reserved")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
slug := base
for i := 2; ; i++ {
n, err := db.Col("orgs").CountDocuments(ctx, bson.M{"slug": slug})
if err != nil {
return nil, err
}
if n == 0 {
break
}
slug = fmt.Sprintf("%s-%d", base, i)
}
o := &models.Org{OrgID: uuid.NewString(), Name: name, Slug: slug, CreatedAt: time.Now()}
if _, err := db.Col("orgs").InsertOne(ctx, o); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, fmt.Errorf("organization slug already taken")
}
return nil, err
}
if created, updated, err := SeedDefaultSteps(o.OrgID); err != nil {
log.Printf("warning: failed to seed default steps for new org %s: %v", o.OrgID, err)
} else {
log.Printf("default steps seeded for new org %s: %d created, %d updated", o.OrgID, created, updated)
}
return o, nil
}
-37
View File
@@ -1,37 +0,0 @@
package services
import (
"testing"
"github.com/mrhid6/vantage/server/internal/models"
)
func TestResolveInlineStep(t *testing.T) {
ref := models.WorkflowStepRef{
Order: 2,
OnFailure: "",
Inline: &models.WorkflowStep{
Name: "adhoc",
Interpreter: "bash",
Script: "echo hi",
SecretRefs: []string{"TOKEN"},
DeclaredInputs: []models.InputParam{
{Name: "REGION", Default: "eu"},
},
},
Inputs: map[string]string{"REGION": "us"},
}
rs := resolveInlineStep(ref)
if rs.Name != "adhoc" || rs.Script != "echo hi" || rs.Order != 2 {
t.Fatalf("bad resolve: %+v", rs)
}
if rs.OnFailure != "stop" {
t.Fatalf("want default on_failure=stop, got %q", rs.OnFailure)
}
if rs.Inputs["REGION"] != "us" {
t.Fatalf("want input override us, got %q", rs.Inputs["REGION"])
}
if len(rs.SecretRefs) != 1 || rs.SecretRefs[0] != "TOKEN" {
t.Fatalf("bad secret refs: %v", rs.SecretRefs)
}
}
+33 -26
View File
@@ -2,6 +2,7 @@ package services
import (
"context"
"errors"
"fmt"
"sort"
"time"
@@ -13,25 +14,36 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// EnsureSecretIndexes creates the unique compound index on (group, key).
func EnsureSecretIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.Col("secrets").Indexes().DropOne(ctx, "group_1_key_1"); err != nil && !isIndexNotFound(err) {
return err
}
_, err := db.Col("secrets").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "group", Value: 1}, {Key: "key", Value: 1}},
Keys: bson.D{{Key: "org_id", Value: 1}, {Key: "group", Value: 1}, {Key: "key", Value: 1}},
Options: options.Index().SetUnique(true),
})
return err
}
// ListSecretGroups returns a summary of every group with its key count and
// most recent update time.
func ListSecretGroups() ([]models.GroupSummary, error) {
func isIndexNotFound(err error) bool {
var ce mongo.CommandError
if errors.As(err, &ce) {
return ce.Code == 27 || ce.Name == "IndexNotFound" ||
ce.Code == 26 || ce.Name == "NamespaceNotFound"
}
return false
}
func ListSecretGroups(orgID string) ([]models.GroupSummary, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
pipeline := mongo.Pipeline{
{{Key: "$match", Value: bson.D{{Key: "org_id", Value: orgID}}}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$group"},
{Key: "key_count", Value: bson.D{{Key: "$sum", Value: 1}}},
@@ -66,13 +78,11 @@ func ListSecretGroups() ([]models.GroupSummary, error) {
return groups, nil
}
// GetSecretGroup returns the keys within a group, sorted by key name, without
// decrypted values.
func GetSecretGroup(group string) ([]models.Secret, error) {
func GetSecretGroup(orgID, group string) ([]models.Secret, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("secrets").Find(ctx, bson.M{"group": group},
cursor, err := db.Col("secrets").Find(ctx, bson.M{"org_id": orgID, "group": group},
options.Find().SetSort(bson.D{{Key: "key", Value: 1}}))
if err != nil {
return nil, err
@@ -86,10 +96,8 @@ func GetSecretGroup(group string) ([]models.Secret, error) {
return docs, nil
}
// GetSecretGroupDecrypted returns a flat map of key → plaintext value for a
// group. Used by the ESO read endpoint.
func GetSecretGroupDecrypted(group string) (map[string]string, error) {
docs, err := GetSecretGroup(group)
func GetSecretGroupDecrypted(orgID, group string) (map[string]string, error) {
docs, err := GetSecretGroup(orgID, group)
if err != nil {
return nil, err
}
@@ -104,13 +112,12 @@ func GetSecretGroupDecrypted(group string) (map[string]string, error) {
return result, nil
}
// RevealSecret returns the decrypted value of a single key.
func RevealSecret(group, key string) (string, error) {
func RevealSecret(orgID, group, key string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var doc models.Secret
err := db.Col("secrets").FindOne(ctx, bson.M{"group": group, "key": key}).Decode(&doc)
err := db.Col("secrets").FindOne(ctx, bson.M{"org_id": orgID, "group": group, "key": key}).Decode(&doc)
if err == mongo.ErrNoDocuments {
return "", fmt.Errorf("secret not found")
}
@@ -120,8 +127,7 @@ func RevealSecret(group, key string) (string, error) {
return decryptString(doc.EncryptedValue)
}
// UpsertSecrets encrypts and writes each key/value pair into the group.
func UpsertSecrets(group string, values map[string]string) error {
func UpsertSecrets(orgID, group string, values map[string]string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -131,8 +137,9 @@ func UpsertSecrets(group string, values map[string]string) error {
return fmt.Errorf("encrypt %s: %w", key, err)
}
_, err = db.Col("secrets").UpdateOne(ctx,
bson.M{"group": group, "key": key},
bson.M{"org_id": orgID, "group": group, "key": key},
bson.M{"$set": bson.M{
"org_id": orgID,
"encrypted_value": encrypted,
"updated_at": time.Now(),
}},
@@ -145,7 +152,7 @@ func UpsertSecrets(group string, values map[string]string) error {
return nil
}
// SortedKeys returns the map keys sorted — handy for stable audit messages.
func SortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
@@ -155,20 +162,20 @@ func SortedKeys(m map[string]string) []string {
return keys
}
// DeleteSecret removes a single key from a group.
func DeleteSecret(group, key string) error {
func DeleteSecret(orgID, group, key string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("secrets").DeleteOne(ctx, bson.M{"group": group, "key": key})
_, err := db.Col("secrets").DeleteOne(ctx, bson.M{"org_id": orgID, "group": group, "key": key})
return err
}
// DeleteSecretGroup removes an entire group and all its keys.
func DeleteSecretGroup(group string) error {
func DeleteSecretGroup(orgID, group string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("secrets").DeleteMany(ctx, bson.M{"group": group})
_, err := db.Col("secrets").DeleteMany(ctx, bson.M{"org_id": orgID, "group": group})
return err
}
+97 -45
View File
@@ -6,6 +6,7 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"log"
"strings"
"time"
@@ -29,13 +30,14 @@ func HashToken(token string) string {
return hex.EncodeToString(sum[:])
}
func CreateServer() (*models.Server, string, error) {
func CreateServer(orgID string) (*models.Server, string, error) {
token, err := generateToken(32)
if err != nil {
return nil, "", err
}
expires := time.Now().Add(time.Hour)
s := &models.Server{
OrgID: orgID,
ServerID: uuid.NewString(),
PreRegToken: token,
PreRegExpires: &expires,
@@ -52,7 +54,22 @@ func CreateServer() (*models.Server, string, error) {
return s, token, nil
}
func GetServer(serverID string) (*models.Server, error) {
func GetServer(orgID, serverID string) (*models.Server, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.Server
err := db.Col("servers").FindOne(ctx, bson.M{"server_id": serverID, "org_id": orgID}).Decode(&s)
if err != nil {
return nil, err
}
return &s, nil
}
func getServerByID(serverID string) (*models.Server, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -79,9 +96,9 @@ func GetServerByPreRegToken(token string) (*models.Server, error) {
return &s, nil
}
// OSTypeFromInfo derives a coarse os_type ("windows" or "linux") from the
// agent-reported os_info string, which is formatted "<GOOS> <GOARCH>".
// Anything that is not explicitly windows defaults to linux.
func OSTypeFromInfo(osInfo string) string {
if strings.HasPrefix(strings.ToLower(osInfo), "windows") {
return "windows"
@@ -89,8 +106,8 @@ func OSTypeFromInfo(osInfo string) string {
return "linux"
}
// defaultConsoleFields returns the initial console configuration for a newly
// registered server based on its os_type.
func defaultConsoleFields(osType string) (protocols []string, sshPort, rdpPort int) {
if osType == "windows" {
return []string{"rdp"}, 22, 3389
@@ -164,14 +181,19 @@ func ValidateAgentToken(serverID, agentToken string) (*models.Server, error) {
if err != nil {
return nil, fmt.Errorf("invalid agent token")
}
if s.OrgID == "" {
return nil, fmt.Errorf("server %s has no org", serverID)
}
return &s, nil
}
// BackfillConsoleConfig sets default console_protocols/ports for a server that
// predates the console feature (or was updated without re-registering). Servers
// register only once via a single-use pre_reg_token, so Register() never runs
// again to populate these fields — this runs on every sync as a cheap no-op
// once the fields are present.
func BackfillConsoleConfig(srv *models.Server) error {
if srv == nil || len(srv.ConsoleProtocols) > 0 {
return nil
@@ -214,12 +236,12 @@ func UpdateServerLastSeen(serverID, agentVersion string) error {
return err
}
func ListServers() ([]models.Server, error) {
func ListServers(orgID string) ([]models.Server, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
cursor, err := db.Col("servers").Find(ctx, bson.M{}, opts)
cursor, err := db.Col("servers").Find(ctx, bson.M{"org_id": orgID}, opts)
if err != nil {
return nil, err
}
@@ -232,16 +254,16 @@ func ListServers() ([]models.Server, error) {
return servers, nil
}
func DeleteServer(serverID string) error {
func DeleteServer(orgID, serverID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID})
_, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID, "org_id": orgID})
if err != nil {
return err
}
// Also remove assignments
_, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID})
_, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID, "org_id": orgID})
return err
}
@@ -261,39 +283,73 @@ func StoreAvailableUpdates(serverID string, pkgs []models.PackageUpdate) error {
}
func MarkOfflineServers() error {
settings, _ := GetSettings()
thresholdMinutes := 5
if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 {
thresholdMinutes = settings.Alerts.OfflineThresholdMinutes
}
threshold := time.Duration(thresholdMinutes) * time.Minute
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cutoff := time.Now().Add(-threshold)
// Find servers about to transition to offline so we can alert on them.
cursor, err := db.Col("servers").Find(ctx, bson.M{
"status": "active",
"last_seen": bson.M{"$lt": cutoff},
})
orgIDs, err := ListOrgIDs()
if err != nil {
return err
}
defer cursor.Close(ctx)
var goingOffline []models.Server
if err := cursor.All(ctx, &goingOffline); err != nil {
return err
for _, orgID := range orgIDs {
if err := markOfflineForFilter(bson.M{"org_id": orgID}, orgID); err != nil {
log.Printf("offline sweep failed for org %s: %v", orgID, err)
}
}
if err := markOfflineForFilter(bson.M{"org_id": bson.M{"$nin": orgIDs}}, ""); err != nil {
log.Printf("offline sweep failed for orphaned servers: %v", err)
}
return nil
}
func markOfflineForFilter(scope bson.M, orgID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var settings *models.Settings
thresholdMinutes := 5
if orgID != "" {
settings, _ = GetSettings(orgID)
if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 {
thresholdMinutes = settings.Alerts.OfflineThresholdMinutes
}
}
cutoff := time.Now().Add(-time.Duration(thresholdMinutes) * time.Minute)
filter := bson.M{
"status": "active",
"last_seen": bson.M{"$lt": cutoff},
}
for k, v := range scope {
filter[k] = v
}
cursor, err := db.Col("servers").Find(ctx, filter)
if err != nil {
return err
}
var goingOffline []models.Server
err = cursor.All(ctx, &goingOffline)
cursor.Close(ctx)
if err != nil {
return err
}
if len(goingOffline) == 0 {
return nil
}
for _, s := range goingOffline {
LogEvent("server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress))
LogEvent(s.OrgID, "server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress))
if settings != nil && settings.Alerts.Enabled && settings.Alerts.WebhookURL != "" {
go SendOfflineWebhook(settings.Alerts.WebhookURL, s.Hostname, s.ServerID, s.IPAddress)
}
@@ -302,11 +358,7 @@ func MarkOfflineServers() error {
}
}
_, err = db.Col("servers").UpdateMany(ctx,
bson.M{
"status": "active",
"last_seen": bson.M{"$lt": cutoff},
},
_, err = db.Col("servers").UpdateMany(ctx, filter,
bson.M{"$set": bson.M{"status": "offline"}},
)
return err
@@ -1,18 +0,0 @@
package services
import "testing"
func TestOSTypeFromInfo(t *testing.T) {
cases := map[string]string{
"windows amd64": "windows",
"linux amd64": "linux",
"linux arm64": "linux",
"": "linux",
"darwin arm64": "linux",
}
for in, want := range cases {
if got := OSTypeFromInfo(in); got != want {
t.Errorf("OSTypeFromInfo(%q) = %q, want %q", in, got, want)
}
}
}
+71 -27
View File
@@ -34,14 +34,47 @@ var defaultSettings = models.Settings{
},
}
func GetSettings() (*models.Settings, error) {
func EnsureSettingsIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.Col("settings").Indexes().DropOne(ctx, "secrets.read_token_hash_1"); err != nil && !isIndexNotFound(err) {
return err
}
if _, err := db.Col("settings").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "org_id", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
_, err := db.Col("settings").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "secrets.read_token_hash", Value: 1}},
Options: options.Index().SetUnique(true).SetName("settings_read_token_hash_unique").
SetPartialFilterExpression(bson.M{
"secrets.read_token_hash": bson.M{"$type": "string", "$gt": ""},
}),
})
return err
}
func GetSettings(orgID string) (*models.Settings, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.Settings
err := db.Col("settings").FindOne(ctx, bson.M{}).Decode(&s)
err := db.Col("settings").FindOne(ctx, bson.M{"org_id": orgID}).Decode(&s)
if err == mongo.ErrNoDocuments {
cp := defaultSettings
cp.OrgID = orgID
return &cp, nil
}
if err != nil {
@@ -56,9 +89,9 @@ func hashToken(token string) string {
return hex.EncodeToString(sum[:])
}
// RotateSecretsReadToken generates a new ESO read token, stores its SHA-256
// hash, and returns the plaintext token exactly once.
func RotateSecretsReadToken() (string, error) {
func RotateSecretsReadToken(orgID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -69,11 +102,14 @@ func RotateSecretsReadToken() (string, error) {
token := hex.EncodeToString(raw)
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{},
bson.M{"$set": bson.M{
"secrets.read_token_hash": hashToken(token),
"secrets.rotated_at": time.Now(),
}},
bson.M{"org_id": orgID},
bson.M{
"$set": bson.M{
"secrets.read_token_hash": hashToken(token),
"secrets.rotated_at": time.Now(),
},
"$setOnInsert": bson.M{"org_id": orgID},
},
options.UpdateOne().SetUpsert(true),
)
if err != nil {
@@ -82,25 +118,33 @@ func RotateSecretsReadToken() (string, error) {
return token, nil
}
// VerifySecretsReadToken reports whether the supplied token matches the stored
// hash, using a constant-time comparison.
func VerifySecretsReadToken(token string) bool {
func ResolveSecretsReadToken(token string) (string, bool) {
if token == "" {
return false
return "", false
}
s, err := GetSettings()
if err != nil || s.Secrets.ReadTokenHash == "" {
return false
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.Settings
err := db.Col("settings").FindOne(ctx, bson.M{"secrets.read_token_hash": hashToken(token)}).Decode(&s)
if err != nil || s.Secrets.ReadTokenHash == "" || s.OrgID == "" {
return "", false
}
expected, err := hex.DecodeString(s.Secrets.ReadTokenHash)
if err != nil {
return false
return "", false
}
got := sha256.Sum256([]byte(token))
return subtle.ConstantTimeCompare(expected, got[:]) == 1
if subtle.ConstantTimeCompare(expected, got[:]) != 1 {
return "", false
}
return s.OrgID, true
}
func SaveSettings(alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error {
func SaveSettings(orgID string, alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -116,17 +160,17 @@ func SaveSettings(alerts models.AlertSettings, email models.EmailSettings, reten
set["workflow_log_retention_days"] = *retentionDays
}
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{},
bson.M{"$set": set},
bson.M{"org_id": orgID},
bson.M{"$set": set, "$setOnInsert": bson.M{"org_id": orgID}},
options.UpdateOne().SetUpsert(true),
)
return err
}
// GetWorkflowLogRetentionDays returns the log retention in days: 30 when unset,
// 0 for keep-forever, or the configured value.
func GetWorkflowLogRetentionDays() (int, error) {
s, err := GetSettings()
func GetWorkflowLogRetentionDays(orgID string) (int, error) {
s, err := GetSettings(orgID)
if err != nil {
return 30, err
}
@@ -197,7 +241,7 @@ func SendOfflineEmail(cfg models.EmailSettings, hostname, serverID, ipAddress st
}
}
// sendMailTLS dials with implicit TLS (port 465) instead of STARTTLS.
func sendMailTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host})
if err != nil {
+10 -10
View File
@@ -9,7 +9,7 @@ import (
const StepDocKind = "vantage.step/v1"
// StepDoc is the portable, id-free representation of a step.
type StepDoc struct {
Kind string `json:"kind"`
Name string `json:"name"`
@@ -21,7 +21,7 @@ type StepDoc struct {
SecretRefs []string `json:"secret_refs"`
}
// ExportStepDoc builds a portable doc from a library step (ids/source stripped).
func ExportStepDoc(s models.WorkflowStep) StepDoc {
return StepDoc{
Kind: StepDocKind,
@@ -35,8 +35,8 @@ func ExportStepDoc(s models.WorkflowStep) StepDoc {
}
}
// ParseStepDoc validates a v1 doc and returns a normalized (id-free) step with
// declared_outputs recomputed from the script.
func ParseStepDoc(b []byte) (models.WorkflowStep, error) {
var d StepDoc
if err := json.Unmarshal(b, &d); err != nil {
@@ -65,20 +65,20 @@ func ParseStepDoc(b []byte) (models.WorkflowStep, error) {
}, nil
}
// ImportStepToLibrary parses a doc and persists it as a new user library step.
func ImportStepToLibrary(b []byte) (*models.WorkflowStep, error) {
func ImportStepToLibrary(orgID string, b []byte) (*models.WorkflowStep, error) {
s, err := ParseStepDoc(b)
if err != nil {
return nil, err
}
return CreateStep(s)
return CreateStep(orgID, s)
}
// ExportStep loads a library step and marshals it to a portable doc.
func ExportStep(stepID string) ([]byte, error) {
func ExportStep(orgID, stepID string) ([]byte, error) {
ctx, cancel := wfCtx()
defer cancel()
s, err := getStep(ctx, stepID)
s, err := getStep(ctx, orgID, stepID)
if err != nil {
return nil, err
}
-60
View File
@@ -1,60 +0,0 @@
package services
import (
"encoding/json"
"testing"
"github.com/mrhid6/vantage/server/internal/models"
)
func mkStep() models.WorkflowStep {
return models.WorkflowStep{
StepID: "should-not-export", Source: "default", Name: "Restart",
Interpreter: "bash", Script: "echo x=1 >> $WORKFLOW_ENV",
SecretRefs: []string{"TOK"},
}
}
func TestParseStepDocValid(t *testing.T) {
raw := `{"kind":"vantage.step/v1","name":"Restart","interpreter":"bash",
"script":"echo x=1 >> $WORKFLOW_ENV","declared_outputs":["stale"],
"declared_inputs":[{"name":"A","default":"1"}],"secret_refs":["TOK"]}`
s, err := ParseStepDoc([]byte(raw))
if err != nil {
t.Fatal(err)
}
if s.Name != "Restart" || s.Interpreter != "bash" {
t.Fatalf("bad parse: %+v", s)
}
// declared_outputs recomputed from script, ignoring the file's ["stale"].
if len(s.DeclaredOutputs) != 1 || s.DeclaredOutputs[0] != "x" {
t.Fatalf("outputs should be derived, got %v", s.DeclaredOutputs)
}
if s.StepID != "" || s.Source != "" {
t.Fatalf("parse must not set id/source")
}
}
func TestParseStepDocBadKind(t *testing.T) {
if _, err := ParseStepDoc([]byte(`{"kind":"nope","name":"x"}`)); err == nil {
t.Fatal("want error for bad kind")
}
}
func TestParseStepDocBadJSON(t *testing.T) {
if _, err := ParseStepDoc([]byte(`{`)); err == nil {
t.Fatal("want error for bad json")
}
}
func TestExportStepDocRoundTrip(t *testing.T) {
doc := ExportStepDoc(mkStep())
b, _ := json.Marshal(doc)
s, err := ParseStepDoc(b)
if err != nil {
t.Fatal(err)
}
if s.Name != "Restart" || s.Interpreter != "bash" {
t.Fatalf("round trip lost data: %+v", s)
}
}
+75 -43
View File
@@ -2,6 +2,7 @@ package services
import (
"bytes"
"log"
"os"
"path/filepath"
"strings"
@@ -10,9 +11,10 @@ import (
"github.com/mrhid6/vantage/server/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// WorkflowLogDir returns the base directory for workflow step logs, creating it.
func WorkflowLogDir() string {
dir := os.Getenv("VANTAGE_WORKFLOW_LOG_DIR")
if dir == "" {
@@ -22,19 +24,19 @@ func WorkflowLogDir() string {
return dir
}
// ServerRunLogPath is the per-server-run log file path.
func ServerRunLogPath(runID, serverID string) string {
return filepath.Join(WorkflowLogDir(), runID, serverID+".log")
}
// logTS is the UTC timestamp prefix stamped on every log line. Stored in UTC
// (RFC3339, millisecond precision); the UI renders it in the viewer's timezone.
func logTS() string {
return time.Now().UTC().Format("2006-01-02T15:04:05.000") + "Z"
}
// AppendMarker writes a timestamped event line to the server-run log and returns
// the byte offset at which the write began (used as a step's log_offset).
func AppendMarker(runID, serverID, text string) (int64, error) {
path := ServerRunLogPath(runID, serverID)
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
@@ -45,19 +47,19 @@ func AppendMarker(runID, serverID, text string) (int64, error) {
return 0, err
}
defer f.Close()
off, _ := f.Seek(0, 2) // current end = offset before write
off, _ := f.Seek(0, 2)
if _, err := f.WriteString("[" + logTS() + "] " + text + "\n"); err != nil {
return off, err
}
return off, nil
}
// ---- streamed chunk writer, boundary-safe secret masking ----
type stepLogWriter struct {
mu sync.Mutex
f *os.File
carry []byte // bytes of an as-yet-unterminated line
carry []byte
secrets []string
}
@@ -68,7 +70,7 @@ type stepLogRegistry struct {
var StepLogs = &stepLogRegistry{writers: make(map[string]*stepLogWriter)}
// Open opens (append) the server-run file for a step's streamed chunks.
func (r *stepLogRegistry) Open(commandID, path string, secrets []string) error {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
@@ -90,10 +92,10 @@ func (r *stepLogRegistry) get(commandID string) *stepLogWriter {
return r.writers[commandID]
}
// Append buffers chunks into whole lines, then writes each complete line with a
// UTC timestamp prefix and secret masking applied. Buffering by line means a
// secret split across a chunk boundary is always masked (the whole line is
// assembled first) and every line carries its own timestamp.
func (r *stepLogRegistry) Append(commandID string, data []byte) {
w := r.get(commandID)
if w == nil {
@@ -113,7 +115,7 @@ func (r *stepLogRegistry) Append(commandID string, data []byte) {
w.carry = append([]byte{}, buf...)
}
// writeLine emits one masked, timestamped log line. Caller holds w.mu.
func (w *stepLogWriter) writeLine(line []byte) {
masked := maskBytes(line, w.secrets)
_, _ = w.f.WriteString("[" + logTS() + "] ")
@@ -121,7 +123,7 @@ func (w *stepLogWriter) writeLine(line []byte) {
_, _ = w.f.WriteString("\n")
}
// Close flushes any trailing partial line and closes the file.
func (r *stepLogRegistry) Close(commandID string) {
r.mu.Lock()
w := r.writers[commandID]
@@ -150,9 +152,9 @@ func maskBytes(b []byte, secrets []string) []byte {
return []byte(s)
}
// ---- retention sweeper ----
// StartLogSweeper sweeps expired run-log dirs hourly (and once now).
func StartLogSweeper() {
go func() {
sweepLogs()
@@ -164,54 +166,84 @@ func StartLogSweeper() {
}()
}
func sweepLogs() {
days := retentionDays()
if days <= 0 {
return
}
cutoff := time.Now().AddDate(0, 0, -days)
base := WorkflowLogDir()
entries, err := os.ReadDir(base)
if err != nil {
return
}
cache := map[string]int{}
now := time.Now()
for _, e := range entries {
if !e.IsDir() {
continue
}
runID := e.Name()
dir := filepath.Join(base, runID)
if runExpired(runID, dir, cutoff) {
orgID, finishedAt, found, err := runRetentionInfo(runID)
if err != nil {
log.Printf("log sweep: retention lookup failed for run %s: %v", runID, err)
continue
}
if found && finishedAt == nil {
continue
}
days, ok := cache[orgID]
if !ok {
days = defaultRetentionDays
if orgID != "" {
if v, err := GetWorkflowLogRetentionDays(orgID); err == nil {
days = v
}
}
cache[orgID] = days
}
if days <= 0 {
continue
}
cutoff := now.AddDate(0, 0, -days)
if found {
if finishedAt.Before(cutoff) {
_ = os.RemoveAll(dir)
}
continue
}
if fi, e := os.Stat(dir); e == nil && fi.ModTime().Before(cutoff) {
_ = os.RemoveAll(dir)
}
}
}
// runExpired is true when the run finished before cutoff (falling back to dir
// mtime when the run doc is gone).
func runExpired(runID, dir string, cutoff time.Time) bool {
const defaultRetentionDays = 30
func runRetentionInfo(runID string) (string, *time.Time, bool, error) {
ctx, cancel := wfCtx()
defer cancel()
var run struct {
OrgID string `bson:"org_id"`
FinishedAt *time.Time `bson:"finished_at"`
}
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run)
if err == nil {
if run.FinishedAt == nil {
return false // still running / never finished — keep
}
return run.FinishedAt.Before(cutoff)
if err == mongo.ErrNoDocuments {
return "", nil, false, nil
}
// run doc gone: use dir mtime
if fi, e := os.Stat(dir); e == nil {
return fi.ModTime().Before(cutoff)
if err != nil {
return "", nil, false, err
}
return false
}
func retentionDays() int {
if v, err := GetWorkflowLogRetentionDays(); err == nil {
return v
}
return 30
return run.OrgID, run.FinishedAt, true, nil
}
+6 -6
View File
@@ -11,12 +11,12 @@ type stepResultRegistry struct {
pending map[string]chan *pb.StepResult
}
// StepResults correlates agent StepResult replies back to the workflow runner
// goroutine that dispatched the matching RunStepCmd, keyed by command_id.
var StepResults = &stepResultRegistry{pending: make(map[string]chan *pb.StepResult)}
// Await registers interest in a command's result BEFORE the command is
// dispatched, and returns a buffered channel that receives the single result.
func (r *stepResultRegistry) Await(commandID string) <-chan *pb.StepResult {
ch := make(chan *pb.StepResult, 1)
r.mu.Lock()
@@ -25,14 +25,14 @@ func (r *stepResultRegistry) Await(commandID string) <-chan *pb.StepResult {
return ch
}
// Cancel removes a pending waiter (call on timeout to avoid leaks).
func (r *stepResultRegistry) Cancel(commandID string) {
r.mu.Lock()
delete(r.pending, commandID)
r.mu.Unlock()
}
// Deliver routes an incoming StepResult to its waiter, if any.
func (r *stepResultRegistry) Deliver(res *pb.StepResult) {
if res == nil {
return
+6 -6
View File
@@ -5,12 +5,12 @@ import (
"strings"
)
// keyAssign matches an env-var assignment target: KEY= (captures KEY).
var keyAssign = regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)=`)
// DeriveOutputs scans a step script and returns the output keys it writes to
// $WORKFLOW_ENV. Best-effort: only lines that reference WORKFLOW_ENV are
// considered. Deduplicated, first-seen order preserved.
func DeriveOutputs(script string) []string {
out := []string{}
seen := map[string]bool{}
@@ -20,7 +20,7 @@ func DeriveOutputs(script string) []string {
}
for _, m := range keyAssign.FindAllStringSubmatch(line, -1) {
key := m[1]
// Skip the sentinel itself (e.g. "WORKFLOW_ENV=..." assignments).
if key == "WORKFLOW_ENV" || key == "env" {
continue
}
@@ -36,7 +36,7 @@ func DeriveOutputs(script string) []string {
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
// Slugify converts a step name into a stable kebab-case slug.
func Slugify(name string) string {
s := strings.ToLower(name)
s = slugStrip.ReplaceAllString(s, "-")
-45
View File
@@ -1,45 +0,0 @@
package services
import (
"reflect"
"testing"
)
func TestDeriveOutputs(t *testing.T) {
script := `#!/bin/bash
echo "test=123" >> $WORKFLOW_ENV
echo "other=hi" >> "$WORKFLOW_ENV"
printf 'third=1\n' >> $WORKFLOW_ENV
echo "test=456" >> $WORKFLOW_ENV
echo "ignored=nope"
NORMAL=assignment
`
got := DeriveOutputs(script)
want := []string{"test", "other", "third"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v want %v", got, want)
}
}
func TestDeriveOutputsPowershell(t *testing.T) {
script := `"result=ok" >> $env:WORKFLOW_ENV
Add-Content $env:WORKFLOW_ENV "count=5"`
got := DeriveOutputs(script)
want := []string{"result", "count"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v want %v", got, want)
}
}
func TestDeriveOutputsNone(t *testing.T) {
got := DeriveOutputs("echo hello\nNOPE=1")
if len(got) != 0 {
t.Fatalf("got %v want empty", got)
}
}
func TestSlugify(t *testing.T) {
if got := Slugify("Restart NGINX Service!"); got != "restart-nginx-service" {
t.Fatalf("got %q", got)
}
}
+9 -1
View File
@@ -13,7 +13,15 @@ func BuildAuthorizedKeys(serverID string) ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv, err := getServerByID(serverID)
if err != nil {
return nil, err
}
cursor, err := db.Col("assignments").Find(ctx, bson.M{
"org_id": srv.OrgID,
"server_id": serverID,
"revoked_at": nil,
})
@@ -30,7 +38,7 @@ func BuildAuthorizedKeys(serverID string) ([]string, error) {
var lines []string
for _, a := range assignments {
var key models.Key
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID}).Decode(&key)
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "org_id": srv.OrgID}).Decode(&key)
if err != nil {
continue
}
+183
View File
@@ -0,0 +1,183 @@
package services
import (
"context"
"errors"
"fmt"
"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"
"go.mongodb.org/mongo-driver/v2/mongo"
"golang.org/x/crypto/bcrypt"
)
var ErrLastOwner = errors.New("this is the organization's last owner promote another member to owner first")
func CountUsers() (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return db.Col("users").CountDocuments(ctx, bson.M{})
}
func CountOrgUsers(orgID string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return db.Col("users").CountDocuments(ctx, bson.M{"org_id": orgID})
}
func countOtherOwners(orgID, exceptUserID string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return db.Col("users").CountDocuments(ctx, bson.M{
"org_id": orgID,
"role": models.RoleOwner,
"user_id": bson.M{"$ne": exceptUserID},
})
}
func GetUserInOrg(orgID, userID string) (*models.User, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var u models.User
err := db.Col("users").FindOne(ctx, bson.M{"user_id": userID, "org_id": orgID}).Decode(&u)
if err != nil {
return nil, err
}
return &u, nil
}
func CreateUser(orgID, email, password, role, authSource string) (*models.User, error) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return nil, fmt.Errorf("email required")
}
if !models.ValidRole(role) {
return nil, fmt.Errorf("invalid role %q", role)
}
u := &models.User{
UserID: uuid.NewString(),
OrgID: orgID,
Email: email,
Role: role,
AuthSource: authSource,
CreatedAt: time.Now(),
}
if password != "" {
hash, err := bcrypt.GenerateFromPassword([]byte(password), 12)
if err != nil {
return nil, err
}
u.PasswordHash = string(hash)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := db.Col("users").InsertOne(ctx, u); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, fmt.Errorf("email already registered")
}
return nil, err
}
return u, nil
}
func GetUserByEmail(email string) (*models.User, error) {
email = strings.ToLower(strings.TrimSpace(email))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var u models.User
err := db.Col("users").FindOne(ctx, bson.M{"email": email}).Decode(&u)
if err != nil {
return nil, err
}
return &u, nil
}
func VerifyPassword(u *models.User, password string) bool {
if u == nil || u.PasswordHash == "" {
return false
}
return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil
}
func TouchLastLogin(userID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
_, err := db.Col("users").UpdateOne(ctx, bson.M{"user_id": userID},
bson.M{"$set": bson.M{"last_login": now}})
return err
}
func ListUsers(orgID string) ([]models.User, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("users").Find(ctx, bson.M{"org_id": orgID})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var users []models.User
if err := cursor.All(ctx, &users); err != nil {
return nil, err
}
return users, nil
}
func UpdateUserRole(orgID, userID, role string) error {
if !models.ValidRole(role) {
return fmt.Errorf("invalid role %q", role)
}
target, err := GetUserInOrg(orgID, userID)
if err != nil {
return fmt.Errorf("user not found")
}
if target.Role == models.RoleOwner && role != models.RoleOwner {
others, err := countOtherOwners(orgID, userID)
if err != nil {
return err
}
if others == 0 {
return ErrLastOwner
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = db.Col("users").UpdateOne(ctx,
bson.M{"user_id": userID, "org_id": orgID},
bson.M{"$set": bson.M{"role": role}})
return err
}
func DeleteUser(orgID, userID string) error {
target, err := GetUserInOrg(orgID, userID)
if err != nil {
return fmt.Errorf("user not found")
}
if target.Role == models.RoleOwner {
others, err := countOtherOwners(orgID, userID)
if err != nil {
return err
}
if others == 0 {
return ErrLastOwner
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = db.Col("users").DeleteOne(ctx, bson.M{"user_id": userID, "org_id": orgID})
return err
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"github.com/mrhid6/vantage/server/internal/models"
)
// ValidateWorkflow checks each step ref sets exactly one of step_id / inline.
func ValidateWorkflow(w models.Workflow) error {
for i, ref := range w.Steps {
hasLib := ref.StepID != ""
-29
View File
@@ -1,29 +0,0 @@
package services
import (
"testing"
"github.com/mrhid6/vantage/server/internal/models"
)
func TestValidateWorkflow(t *testing.T) {
inline := &models.WorkflowStep{Name: "x", Interpreter: "bash", Script: "echo hi"}
cases := []struct {
name string
ref models.WorkflowStepRef
wantErr bool
}{
{"library only", models.WorkflowStepRef{StepID: "abc"}, false},
{"inline only", models.WorkflowStepRef{Inline: inline}, false},
{"both set", models.WorkflowStepRef{StepID: "abc", Inline: inline}, true},
{"neither set", models.WorkflowStepRef{}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateWorkflow(models.Workflow{Steps: []models.WorkflowStepRef{tc.ref}})
if (err != nil) != tc.wantErr {
t.Fatalf("got err=%v want wantErr=%v", err, tc.wantErr)
}
})
}
}
+50 -64
View File
@@ -17,10 +17,8 @@ import (
const stepDispatchGrace = 15 * time.Second
// TriggerWorkflow snapshots the workflow, creates a run doc, and starts a
// background goroutine per target server (parallel fan-out). Returns run_id.
func TriggerWorkflow(workflowID, actor string) (string, error) {
wf, err := GetWorkflow(workflowID)
func TriggerWorkflow(orgID, workflowID, actor string) (string, error) {
wf, err := GetWorkflow(orgID, workflowID)
if err != nil {
return "", err
}
@@ -31,20 +29,24 @@ func TriggerWorkflow(workflowID, actor string) (string, error) {
return "", fmt.Errorf("workflow has no steps")
}
// Reject a concurrent run of the same workflow.
if err := validateTargetServers(orgID, wf.TargetServerIDs); err != nil {
return "", err
}
ctx, cancel := wfCtx()
running := db.Col("workflow_runs").FindOne(ctx, bson.M{"workflow_id": workflowID, "status": "running"})
running := db.Col("workflow_runs").FindOne(ctx, bson.M{"org_id": orgID, "workflow_id": workflowID, "status": "running"})
cancel()
if running.Err() == nil {
return "", fmt.Errorf("workflow already has a run in progress")
}
resolved, err := resolveSteps(wf)
resolved, err := resolveSteps(orgID, wf)
if err != nil {
return "", err
}
run := models.WorkflowRun{
OrgID: orgID,
RunID: uuid.New().String(),
WorkflowID: workflowID,
Name: wf.Name,
@@ -56,7 +58,7 @@ func TriggerWorkflow(workflowID, actor string) (string, error) {
}
for _, sid := range wf.TargetServerIDs {
hostname := sid
if s, e := GetServer(sid); e == nil {
if s, e := getServerByID(sid); e == nil {
hostname = s.Hostname
}
sr := models.ServerRun{ServerID: sid, Hostname: hostname, Status: "queued", RunEnv: map[string]string{}}
@@ -76,9 +78,7 @@ func TriggerWorkflow(workflowID, actor string) (string, error) {
return run.RunID, nil
}
// resolveSteps freezes each workflow step ref into a ResolvedStep by loading the
// library step and applying overrides.
func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) {
func resolveSteps(orgID string, wf *models.Workflow) ([]models.ResolvedStep, error) {
ctx, cancel := wfCtx()
defer cancel()
out := make([]models.ResolvedStep, 0, len(wf.Steps))
@@ -87,7 +87,7 @@ func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) {
out = append(out, resolveInlineStep(ref))
continue
}
lib, err := getStep(ctx, ref.StepID)
lib, err := getStep(ctx, orgID, ref.StepID)
if err != nil {
return nil, err
}
@@ -127,7 +127,6 @@ func resolveSteps(wf *models.Workflow) ([]models.ResolvedStep, error) {
return out, nil
}
// resolveInlineStep freezes an ad-hoc (inline) step ref into a ResolvedStep.
func resolveInlineStep(ref models.WorkflowStepRef) models.ResolvedStep {
in := ref.Inline
inputs := map[string]string{}
@@ -156,16 +155,15 @@ func resolveInlineStep(ref models.WorkflowStepRef) models.ResolvedStep {
}
}
// executeRun fans out one goroutine per server run and waits for all to finish.
func executeRun(runID string) {
run, err := GetRun(runID)
run, err := getRunByID(runID)
if err != nil {
return
}
done := make(chan int, len(run.ServerRuns))
for i := range run.ServerRuns {
go func(idx int) {
runServer(runID, idx, run.Steps, run.ServerRuns[idx].ServerID)
runServer(run.OrgID, runID, idx, run.Steps, run.ServerRuns[idx].ServerID)
done <- idx
}(i)
}
@@ -173,8 +171,7 @@ func executeRun(runID string) {
<-done
}
// Aggregate status.
final, _ := GetRun(runID)
final, _ := getRunByID(runID)
status := "success"
for _, sr := range final.ServerRuns {
if sr.Status == "failed" {
@@ -188,20 +185,18 @@ func executeRun(runID string) {
bson.M{"$set": bson.M{"status": status, "finished_at": now}})
}
// runServer executes the resolved steps sequentially on one server, threading
// output env forward and applying per-step failure policy.
func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID string) {
func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, serverID string) {
now := time.Now()
setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "running", "server_runs.$.started_at": now})
if !Dispatcher.IsConnected(serverID) {
fin := time.Now()
_, _ = AppendMarker(runID, serverID, "agent not connected server skipped")
_, _ = AppendMarker(runID, serverID, "agent not connected server skipped")
setServerRun(runID, srvIdx, bson.M{"server_runs.$.status": "skipped", "server_runs.$.finished_at": fin})
return
}
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("run started on %s %d step(s), workspace vantage-run-%s", serverID, len(steps), runID))
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("run started on %s %d step(s), workspace vantage-run-%s", serverID, len(steps), runID))
runEnv := map[string]string{}
allSecrets := map[string]string{}
@@ -217,14 +212,11 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
maxAttempts = step.MaxRetries + 1
}
// Merge secrets into command env (kept out of persisted logs).
secretVals := resolveSecrets(step.SecretRefs)
secretVals := resolveSecrets(orgID, step.SecretRefs)
for k, v := range secretVals {
allSecrets[k] = v
}
// Input values may template earlier step outputs and secrets, e.g.
// URL="http://example.com/$VersionNumber". Expand against runEnv (outputs
// threaded from prior steps) and this step's secrets before dispatch.
subst := map[string]string{}
for k, v := range runEnv {
subst[k] = v
@@ -243,8 +235,6 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
cmdEnv[k] = v
}
// Write the step marker to the server-run log and remember the offset so
// the UI can slice this step's output later.
marker := fmt.Sprintf("===== step %d/%d: %s (%s) =====", step.Order+1, len(steps), step.Name, step.Interpreter)
offset, _ := AppendMarker(runID, serverID, marker)
logPath := ServerRunLogPath(runID, serverID)
@@ -256,8 +246,7 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
if attempts > 1 {
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("retry %d/%d after failure", attempts-1, maxAttempts-1))
}
// Open a fresh writer per attempt; the agent's eof closes it, and the
// defensive Close below covers a missing result.
_ = StepLogs.Open(commandID, logPath, secretsSlice)
res = dispatchAndWait(serverID, commandID, &pb.RunStepCmd{
Interpreter: step.Interpreter,
@@ -266,18 +255,18 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
TimeoutSeconds: 0,
WorkspaceId: runID,
})
StepLogs.Close(commandID) // idempotent; no-op if eof already closed it
StepLogs.Close(commandID)
if res != nil && res.ExitCode == 0 {
break
}
}
exit := 1
outEnv := map[string]string{} // masked copy, safe to persist
outEnv := map[string]string{}
if res != nil {
exit = res.ExitCode
for k, v := range res.OutputEnv {
runEnv[k] = v // real, unmasked value threads forward to later steps
runEnv[k] = v
outEnv[k] = maskSecrets(v, allSecrets)
}
} else {
@@ -291,26 +280,24 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
finishStep(runID, serverID, i, status, attempts, exit, offset, outEnv)
dur := time.Since(stepStart).Round(time.Millisecond)
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("step %d/%d %s exit %d, %d attempt(s), %s",
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("step %d/%d %s exit %d, %d attempt(s), %s",
step.Order+1, len(steps), status, exit, attempts, dur))
if exit != 0 {
switch step.OnFailure {
case "continue":
_, _ = AppendMarker(runID, serverID, "on_failure=continue proceeding to next step")
default: // "stop" or exhausted "retry"
_, _ = AppendMarker(runID, serverID, "on_failure=continue proceeding to next step")
default:
serverFailed = true
}
if serverFailed {
_, _ = AppendMarker(runID, serverID, "stopping run remaining steps skipped")
_, _ = AppendMarker(runID, serverID, "stopping run remaining steps skipped")
markRemainingSkipped(runID, serverID, i+1)
break
}
}
}
// Tell the agent to remove the run's working directory now that its steps are
// done (success or failure). Best-effort; the OS reclaims temp dirs anyway.
DispatchCleanupWorkspace(serverID, runID)
fin := time.Now()
@@ -318,10 +305,9 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
if serverFailed {
status = "failed"
}
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("run %s in %s workspace removed",
_, _ = AppendMarker(runID, serverID, fmt.Sprintf("run %s in %s workspace removed",
status, fin.Sub(now).Round(time.Millisecond)))
// Persist only a masked copy of runEnv; the real (unmasked) runEnv was already
// used above to build cmdEnv for each step and must never be written to the DB.
maskedRunEnv := make(map[string]string, len(runEnv))
for k, v := range runEnv {
maskedRunEnv[k] = maskSecrets(v, allSecrets)
@@ -333,8 +319,6 @@ func runServer(runID string, srvIdx int, steps []models.ResolvedStep, serverID s
})
}
// dispatchAndWait registers a waiter, dispatches the step, and blocks for the
// result or a timeout.
func dispatchAndWait(serverID, commandID string, cmd *pb.RunStepCmd) *pb.StepResult {
ch := StepResults.Await(commandID)
if err := DispatchRunStep(serverID, commandID, cmd); err != nil {
@@ -354,9 +338,6 @@ func dispatchAndWait(serverID, commandID string, cmd *pb.RunStepCmd) *pb.StepRes
}
}
// expandVars substitutes $VAR and ${VAR} references in an input value from the
// given lookup (prior step outputs and secrets). Unknown references expand to
// empty, matching shell behaviour; a literal "$" is written as "$$".
func expandVars(v string, lookup map[string]string) string {
return os.Expand(v, func(name string) string {
if name == "$" {
@@ -366,15 +347,15 @@ func expandVars(v string, lookup map[string]string) string {
})
}
func resolveSecrets(refs []string) map[string]string {
func resolveSecrets(orgID string, refs []string) map[string]string {
out := map[string]string{}
for _, ref := range refs {
// ref format "group/KEY"; resolve via RevealSecret.
parts := strings.SplitN(ref, "/", 2)
if len(parts) != 2 {
continue
}
if v, err := RevealSecret(parts[0], parts[1]); err == nil {
if v, err := RevealSecret(orgID, parts[0], parts[1]); err == nil {
out[parts[1]] = v
}
}
@@ -391,8 +372,6 @@ func maskSecrets(s string, secrets map[string]string) string {
return s
}
// ---- run doc mutation helpers ----
func setServerRun(runID string, srvIdx int, set bson.M) {
ctx, cancel := wfCtx()
defer cancel()
@@ -401,9 +380,8 @@ func setServerRun(runID string, srvIdx int, set bson.M) {
bson.M{"$set": set})
}
// serverIDAt returns the server_id at an index (positional operator needs a match).
func serverIDAt(runID string, srvIdx int) string {
r, err := GetRun(runID)
r, err := getRunByID(runID)
if err != nil || srvIdx >= len(r.ServerRuns) {
return ""
}
@@ -430,7 +408,6 @@ func finishStep(runID, serverID string, order int, status string, attempts, exit
})
}
// secretValues returns just the values of a secret map, for masking log output.
func secretValues(m map[string]string) []string {
out := make([]string, 0, len(m))
for _, v := range m {
@@ -465,9 +442,7 @@ func updateStep(runID, serverID string, order int, set bson.M) {
)
}
// ---- reads ----
func GetRun(runID string) (*models.WorkflowRun, error) {
func getRunByID(runID string) (*models.WorkflowRun, error) {
ctx, cancel := wfCtx()
defer cancel()
var r models.WorkflowRun
@@ -478,10 +453,21 @@ func GetRun(runID string) (*models.WorkflowRun, error) {
return &r, err
}
func ListRuns(workflowID string, limit int64) ([]models.WorkflowRun, error) {
func GetRun(orgID, runID string) (*models.WorkflowRun, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"workflow_id": workflowID},
var r models.WorkflowRun
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID, "org_id": orgID}).Decode(&r)
if err == mongo.ErrNoDocuments {
return nil, fmt.Errorf("run not found")
}
return &r, err
}
func ListRuns(orgID, workflowID string, limit int64) ([]models.WorkflowRun, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"org_id": orgID, "workflow_id": workflowID},
options.Find().SetSort(bson.D{{Key: "started_at", Value: -1}}).SetLimit(limit))
if err != nil {
return nil, err
@@ -494,12 +480,12 @@ func ListRuns(workflowID string, limit int64) ([]models.WorkflowRun, error) {
return runs, nil
}
func CancelRun(runID string) error {
func CancelRun(orgID, runID string) error {
now := time.Now()
ctx, cancel := wfCtx()
defer cancel()
_, err := db.Col("workflow_runs").UpdateOne(ctx,
bson.M{"run_id": runID, "status": "running"},
bson.M{"org_id": orgID, "run_id": runID, "status": "running"},
bson.M{"$set": bson.M{"status": "cancelled", "finished_at": now}})
return err
}
+54 -29
View File
@@ -25,8 +25,13 @@ func EnsureWorkflowIndexes() error {
}); err != nil {
return err
}
if err := db.Col("workflow_steps").Indexes().DropOne(ctx, "slug_1"); err != nil && !isIndexNotFound(err) {
return err
}
if _, err := db.Col("workflow_steps").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "slug", Value: 1}},
Keys: bson.D{{Key: "org_id", Value: 1}, {Key: "slug", Value: 1}},
Options: options.Index().SetUnique(true).
SetPartialFilterExpression(bson.M{"source": "default"}),
}); err != nil {
@@ -43,12 +48,12 @@ func EnsureWorkflowIndexes() error {
return err
}
// ---- Steps ----
func ListSteps() ([]models.WorkflowStep, error) {
func ListSteps(orgID string) ([]models.WorkflowStep, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflow_steps").Find(ctx, bson.M{},
cur, err := db.Col("workflow_steps").Find(ctx, bson.M{"org_id": orgID},
options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
@@ -61,12 +66,12 @@ func ListSteps() ([]models.WorkflowStep, error) {
return steps, nil
}
// StepUsageCounts returns, per library step_id, the number of distinct
// workflows that reference it. Inline steps have no step_id and are ignored.
func StepUsageCounts() (map[string]int, error) {
func StepUsageCounts(orgID string) (map[string]int, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflows").Find(ctx, bson.M{})
cur, err := db.Col("workflows").Find(ctx, bson.M{"org_id": orgID})
if err != nil {
return nil, err
}
@@ -89,9 +94,10 @@ func StepUsageCounts() (map[string]int, error) {
return counts, nil
}
func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) {
func CreateStep(orgID string, s models.WorkflowStep) (*models.WorkflowStep, error) {
ctx, cancel := wfCtx()
defer cancel()
s.OrgID = orgID
s.StepID = uuid.New().String()
s.CreatedAt = time.Now()
s.UpdatedAt = s.CreatedAt
@@ -111,10 +117,10 @@ func CreateStep(s models.WorkflowStep) (*models.WorkflowStep, error) {
return &s, nil
}
func UpdateStep(stepID string, s models.WorkflowStep) error {
func UpdateStep(orgID, stepID string, s models.WorkflowStep) error {
ctx, cancel := wfCtx()
defer cancel()
_, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID}, bson.M{"$set": bson.M{
_, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}, bson.M{"$set": bson.M{
"name": s.Name,
"description": s.Description,
"interpreter": s.Interpreter,
@@ -127,14 +133,14 @@ func UpdateStep(stepID string, s models.WorkflowStep) error {
return err
}
func DeleteStep(stepID string) error {
func DeleteStep(orgID, stepID string) error {
ctx, cancel := wfCtx()
defer cancel()
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID}); err != nil {
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}); err != nil {
return err
}
// Cascade: remove this step from every workflow that references it, re-sequencing orders.
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID})
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID, "org_id": orgID})
if err != nil {
return err
}
@@ -164,21 +170,21 @@ func DeleteStep(stepID string) error {
return nil
}
func getStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) {
func getStep(ctx context.Context, orgID, stepID string) (*models.WorkflowStep, error) {
var s models.WorkflowStep
err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID}).Decode(&s)
err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}).Decode(&s)
if err == mongo.ErrNoDocuments {
return nil, fmt.Errorf("step %s not found", stepID)
}
return &s, err
}
// ---- Workflows ----
func ListWorkflows() ([]models.Workflow, error) {
func ListWorkflows(orgID string) ([]models.Workflow, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflows").Find(ctx, bson.M{},
cur, err := db.Col("workflows").Find(ctx, bson.M{"org_id": orgID},
options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
@@ -191,20 +197,21 @@ func ListWorkflows() ([]models.Workflow, error) {
return wfs, nil
}
func GetWorkflow(id string) (*models.Workflow, error) {
func GetWorkflow(orgID, id string) (*models.Workflow, error) {
ctx, cancel := wfCtx()
defer cancel()
var w models.Workflow
err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id}).Decode(&w)
err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}).Decode(&w)
if err == mongo.ErrNoDocuments {
return nil, fmt.Errorf("workflow not found")
}
return &w, err
}
func CreateWorkflow(w models.Workflow) (*models.Workflow, error) {
func CreateWorkflow(orgID string, w models.Workflow) (*models.Workflow, error) {
ctx, cancel := wfCtx()
defer cancel()
w.OrgID = orgID
w.WorkflowID = uuid.New().String()
w.CreatedAt = time.Now()
w.UpdatedAt = w.CreatedAt
@@ -217,6 +224,9 @@ func CreateWorkflow(w models.Workflow) (*models.Workflow, error) {
if err := ValidateWorkflow(w); err != nil {
return nil, err
}
if err := validateTargetServers(orgID, w.TargetServerIDs); err != nil {
return nil, err
}
normalizeInlineSteps(&w)
if _, err := db.Col("workflows").InsertOne(ctx, w); err != nil {
return nil, err
@@ -224,14 +234,17 @@ func CreateWorkflow(w models.Workflow) (*models.Workflow, error) {
return &w, nil
}
func UpdateWorkflow(id string, w models.Workflow) error {
func UpdateWorkflow(orgID, id string, w models.Workflow) error {
ctx, cancel := wfCtx()
defer cancel()
if err := ValidateWorkflow(w); err != nil {
return err
}
if err := validateTargetServers(orgID, w.TargetServerIDs); err != nil {
return err
}
normalizeInlineSteps(&w)
_, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id}, bson.M{"$set": bson.M{
_, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}, bson.M{"$set": bson.M{
"name": w.Name,
"target_server_ids": w.TargetServerIDs,
"steps": w.Steps,
@@ -240,8 +253,20 @@ func UpdateWorkflow(id string, w models.Workflow) error {
return err
}
// normalizeInlineSteps derives outputs for inline steps and strips fields that
// only belong to library steps.
func validateTargetServers(orgID string, serverIDs []string) error {
for _, sid := range serverIDs {
if _, err := GetServer(orgID, sid); err != nil {
return fmt.Errorf("target server %s not found", sid)
}
}
return nil
}
func normalizeInlineSteps(w *models.Workflow) {
for i := range w.Steps {
in := w.Steps[i].Inline
@@ -263,9 +288,9 @@ func normalizeInlineSteps(w *models.Workflow) {
}
}
func DeleteWorkflow(id string) error {
func DeleteWorkflow(orgID, id string) error {
ctx, cancel := wfCtx()
defer cancel()
_, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id})
_, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id, "org_id": orgID})
return err
}
@@ -1,66 +0,0 @@
package services
import (
"os"
"testing"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
)
var mongoAvailable bool
func TestMain(m *testing.M) {
uri := os.Getenv("VANTAGE_TEST_MONGO_URI")
if uri == "" {
uri = "mongodb://localhost:27117"
}
if err := db.Connect(uri, "vantage_test"); err != nil {
// No MongoDB available in this environment; DB-backed tests will be skipped
// individually, but the rest of the package's tests must still run.
mongoAvailable = false
} else {
mongoAvailable = true
}
os.Exit(m.Run())
}
func mkUsageStep(name string) models.WorkflowStep {
return models.WorkflowStep{Name: name, Interpreter: "bash", Script: "echo hi"}
}
func mkWorkflowWithStep(name, stepID string) models.Workflow {
return models.Workflow{Name: name, Steps: []models.WorkflowStepRef{{StepID: stepID, Order: 0, OnFailure: "stop"}}}
}
func TestStepUsageCounts(t *testing.T) {
if !mongoAvailable {
t.Skip("mongo unavailable: set VANTAGE_TEST_MONGO_URI")
}
// A step used by two workflows, a step used by none.
used, err := CreateStep(mkUsageStep("used-step"))
if err != nil {
t.Fatal(err)
}
unused, err := CreateStep(mkUsageStep("unused-step"))
if err != nil {
t.Fatal(err)
}
if _, err := CreateWorkflow(mkWorkflowWithStep("wf-a", used.StepID)); err != nil {
t.Fatal(err)
}
if _, err := CreateWorkflow(mkWorkflowWithStep("wf-b", used.StepID)); err != nil {
t.Fatal(err)
}
counts, err := StepUsageCounts()
if err != nil {
t.Fatal(err)
}
if counts[used.StepID] != 2 {
t.Fatalf("used step: want 2, got %d", counts[used.StepID])
}
if counts[unused.StepID] != 0 {
t.Fatalf("unused step: want 0, got %d", counts[unused.StepID])
}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
out
.env*
npm-debug.log*
+51
View File
@@ -0,0 +1,51 @@
# Dependencies stage
FROM node:26-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
# Build stage
FROM node:26-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Baked in at build time: NEXT_PUBLIC_* values are inlined into the client
# bundle. SITE_API is the browser-reachable URL of sitesvc, which serves both
# forms. It is effectively required: leave it empty and both forms report they
# are not connected rather than submitting anywhere. Must be an origin the
# browser can reach (not the internal sitesvc:8082) and be listed in sitesvc's
# SITE_ORIGIN for CORS.
ARG NEXT_PUBLIC_SITE_API=""
ARG NEXT_PUBLIC_CONTACT_EMAIL="support@hostxtra.co.uk"
ENV NEXT_PUBLIC_SITE_API=$NEXT_PUBLIC_SITE_API
ENV NEXT_PUBLIC_CONTACT_EMAIL=$NEXT_PUBLIC_CONTACT_EMAIL
RUN npm run build
# Runtime stage
FROM node:26-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+131
View File
@@ -0,0 +1,131 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About",
description:
"Vantage began as a weekend fix for a lost laptop and grew into a fleet control plane. How it is built, and what it deliberately does not do.",
};
export default function AboutPage() {
return (
<>
<section className="rail band band--open">
<span className="tag">About</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1.1rem", maxWidth: "20ch" }}>
Built for the fleet nobody was given a budget to manage.
</h1>
<div className="split" style={{ marginTop: "2.4rem" }}>
<div className="prose">
<p>
Vantage started as a weekend fix for a bad afternoon. A laptop was lost, and finding every server that
trusted its key meant SSHing into each one with a text editor open. The list lived in someone&apos;s head.
Two of the boxes were not on it.
</p>
<p>
The obvious tools were all heavier than the problem. A configuration management stack to write one file. A
bastion host that becomes the thing you now have to keep alive. A certificate authority with a rotation
story nobody wanted to own.
</p>
<p>
So it began with one job done properly: hold <code>authorized_keys</code> to a known state. Then the same
agent turned out to be the right place to run a deploy script, check whether a service was answering, and
open a shell when something was on fire. Each addition had to earn its place by riding the connection that
already existed.
</p>
<p>
Today it runs across homelabs, small hosting providers, and agencies who inherit client servers and need
to prove who can reach them.
</p>
</div>
<div>
<span className="tag">How it is built</span>
<div className="specs">
<div className="spec">
<span className="spec__k">SERVER</span>
<div>
<h3>Go, MongoDB, Redis</h3>
<p>
One Go binary serving REST for the interface and gRPC for agents. MongoDB holds everything durable;
Redis holds sessions and nothing else.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">WEB</span>
<div>
<h3>Next.js</h3>
<p>
An operations interface, not a brochure: dense tables, live log streams, and state you can read at a
glance.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">AGENT</span>
<div>
<h3>Go, Linux and Windows</h3>
<p>
A single static binary under systemd or as a Windows service. No runtime, no dependencies, no
package manager involved.
</p>
</div>
</div>
<div className="spec">
<span className="spec__k">CONSOLE</span>
<div>
<h3>Guacamole</h3>
<p>
Protocol handling is a solved problem. We proxy the connection and manage the credentials around it.
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">Security posture</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>
The parts worth being specific about.
</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Tokens</span>
<h3>Hashed, never stored plain</h3>
<p>
Agent tokens and the secrets read token are held as SHA-256 hashes. The plaintext exists on the
agent&apos;s own disk at 0600 and nowhere else.
</p>
</article>
<article className="cap">
<span className="cap__k">At rest</span>
<h3>AES-256-GCM</h3>
<p>
Private keys, passphrases, vault secrets, identity provider secrets and console credentials are encrypted
with a key held only by your deployment.
</p>
</article>
<article className="cap">
<span className="cap__k">One-time</span>
<h3>Tokens that expire and spend</h3>
<p>
Pre-registration tokens last an hour and work once. Console session tokens are consumed the moment the
tunnel opens.
</p>
</article>
<article className="cap">
<span className="cap__k">Recorded</span>
<h3>Every mutation is audited</h3>
<p>
Assignments, revocations, runs, console sessions, secret reveals and settings changes are attributed and
kept.
</p>
</article>
</div>
</section>
</>
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { Metadata } from "next";
import { ContactForm } from "@/components/ContactForm";
export const metadata: Metadata = {
title: "Contact",
description: "Sales questions, self-hosted licensing, security disclosures and bug reports.",
};
const CHANNELS = [
{
title: "Support",
body: "Everything else, including anything urgent.",
link: "support@hostxtra.co.uk",
href: "mailto:support@hostxtra.co.uk",
},
{
title: "Security disclosure",
body: "Encrypted reports, acknowledged within 72 hours.",
link: "support@hostxtra.co.uk",
href: "mailto:support@hostxtra.co.uk?subject=Security%20disclosure",
},
{
title: "Bugs and feature requests",
body: "Public tracker, read by the people who write the code.",
link: "git.vantage.hostxtra.co.uk/vantage",
href: "https://git.vantage.hostxtra.co.uk/vantage",
},
{
title: "Status",
body: "Control plane uptime and incident history.",
link: "status.vantage.hostxtra.co.uk",
href: "https://status.vantage.hostxtra.co.uk",
},
];
export default function ContactPage() {
return (
<section className="rail band band--open">
<span className="tag">Contact</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Tell us what your fleet looks like.</h1>
<div className="split" style={{ marginTop: "2.4rem" }}>
<div className="card">
<ContactForm />
</div>
<div>
<p className="prose">Pick the right door and you will get a faster answer.</p>
{CHANNELS.map((channel) => (
<div className="chan" key={channel.title}>
<h3>{channel.title}</h3>
<p>{channel.body}</p>
<a href={channel.href}>{channel.link}</a>
</div>
))}
</div>
</div>
</section>
);
}
+1180
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import "./globals.css";
import { Footer } from "@/components/Footer";
import { Nav } from "@/components/Nav";
import { ThemeScript } from "@/components/ThemeScript";
export const metadata: Metadata = {
metadataBase: new URL("https://vantage.hostxtra.co.uk"),
title: {
default: "Vantage one control plane for the whole fleet",
template: "%s Vantage",
},
description: "Self-hosted fleet control: SSH key assignment, workflow execution, service monitoring, a secrets vault and a browser console, across every server you manage.",
openGraph: {
type: "website",
siteName: "Vantage",
title: "Vantage one control plane for the whole fleet",
description: "Self-hosted fleet control: SSH keys, workflows, monitors, secrets and consoles, over one outbound agent connection.",
},
icons: { icon: "/images/vantage_logo.svg" },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en-GB">
<head>
<ThemeScript />
</head>
<body>
<a className="skip" href="#main">
Skip to content
</a>
<Nav />
<main id="main">{children}</main>
<Footer />
</body>
</html>
);
}
+180
View File
@@ -0,0 +1,180 @@
import Link from "next/link";
import { InstrumentPanel } from "@/components/InstrumentPanel";
export default function OverviewPage() {
return (
<>
<div className="heroband">
<section className="rail hero">
<span className="tag">Self-hosted fleet control plane</span>
<h1>Your servers, under one pane of glass you actually own.</h1>
<p className="lede">
Vantage holds SSH keys, runs scripts, watches services, stores secrets and opens consoles across every machine you manage. One agent per server, outbound connections only, all
state in your own database.
</p>
<div className="hero__acts">
<Link className="btn btn--solid" href="/start">
Create your organisation
</Link>
<Link className="btn btn--line" href="/platform">
What it does
</Link>
</div>
<p className="hero__foot">Free for 3 servers · Linux and Windows agents · Self-host the whole stack</p>
</section>
</div>
<InstrumentPanel />
<section className="rail band band--open">
<span className="tag">The problem</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>Six tools, six sources of truth, one afternoon lost.</h2>
<div className="split" style={{ marginTop: "2rem" }}>
<p className="prose">
Most small fleets end up with keys in a spreadsheet, scripts in someone&apos;s home directory, uptime checks in a separate service, secrets in a chat thread, and no record of
who ran what. None of those systems know about each other, so every question who can reach this box, what ran on it last, is it even up gets answered by hand.
</p>
<div className="specs specs--flush">
<div className="spec">
<span className="spec__k">ONE AGENT</span>
<div>
<h3>Everything rides one connection</h3>
<p>Keys, steps, checks and inventory all travel over the same outbound link. Installing a second thing is not the answer.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">ONE RECORD</span>
<div>
<h3>Every change is an audit event</h3>
<p>Assignments, revocations, runs, console sessions and settings changes all land in the same log, attributed to a person.</p>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">Capabilities</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>One control plane, six jobs.</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Access</span>
<h3>SSH keys</h3>
<p>
Assign public keys per server and revoke them softly. The agent diffs desired state against the file and rewrites <code>authorized_keys</code> atomically.
</p>
<ul>
<li>fingerprint deduplication</li>
<li>agent-side keypair generation</li>
<li>revocation history preserved</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Execution</span>
<h3>Workflows</h3>
<p>A library of bash and PowerShell steps with declared inputs, outputs and secret references, composed into workflows that target a set of servers.</p>
<ul>
<li>live streamed step logs</li>
<li>stop, continue or retry on failure</li>
<li>values passed between steps</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Uptime</span>
<h3>Monitors</h3>
<p>HTTP, TCP, ICMP and TLS checks, run either from the control plane or from an agent inside the target network.</p>
<ul>
<li>incidents and uptime history</li>
<li>certificate expiry warnings</li>
<li>alerts to five channel types</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Secrets</span>
<h3>Vault</h3>
<p>Grouped key/value secrets encrypted with AES-256-GCM, injected into workflow steps at execution and never written to logs.</p>
<ul>
<li>read token for External Secrets Operator</li>
<li>rotatable, hashed at rest</li>
<li>reveal is an audited action</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Access</span>
<h3>Browser console</h3>
<p>Open an SSH, RDP or VNC session in the browser. SSH authenticates with a stored key, and every session is recorded in the audit log.</p>
<ul>
<li>one-time session tokens</li>
<li>credentials consumed on connect</li>
<li>no client software</li>
</ul>
</article>
<article className="cap">
<span className="cap__k">Health</span>
<h3>Inventory and updates</h3>
<p>CPU, memory, swap, disks and kernel reported continuously, alongside pending OS package updates you can apply from the interface.</p>
<ul>
<li>metrics every 30 seconds</li>
<li>one-click package updates</li>
<li>agents update themselves</li>
</ul>
</article>
</div>
</section>
<section className="rail band">
<span className="tag">Getting started</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "20ch" }}>Four steps, and the first three take a minute.</h2>
<div className="flow">
<div className="flow__c">
<span className="flow__n">FIRST</span>
<h3>Create an organisation</h3>
<p>You become the owner. Everything inside is invisible to every other organisation.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
<h3>Add a server</h3>
<p>Copy the install one-liner. It expires in an hour and works exactly once.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
<h3>Assign a key</h3>
<p>Paste a public key and tick the servers. It lands inside 30 seconds.</p>
</div>
<div className="flow__c">
<span className="flow__n">AFTER</span>
<h3>Build from there</h3>
<p>Add checks, write a step, invite the team, connect your identity provider.</p>
</div>
</div>
<pre className="code" style={{ marginTop: "1.6rem" }}>
<i># Linux</i>
{"\n"}
<b>curl</b> -fsSL https://vantage.hostxtra.co.uk/install | bash -s -- --server-id=<b>$ID</b> --token=<b>$TOKEN</b>
{"\n\n"}
<i># Windows</i>
{"\n"}
<b>irm</b> https://vantage.hostxtra.co.uk/install.ps1 | <b>iex</b>
</pre>
</section>
<section className="rail band band--flush">
<div className="card card--cta">
<div style={{ maxWidth: "48ch" }}>
<h2 style={{ fontSize: "var(--s-1)" }}>Three servers, free, no card.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.4rem", fontSize: "0.94rem" }}>Create an organisation, install one agent, and watch a key land on a real box.</p>
</div>
<Link className="btn btn--solid" href="/start">
Get started
</Link>
</div>
</section>
</>
);
}
+149
View File
@@ -0,0 +1,149 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Platform",
description: "How Vantage fits together: a control plane you run, one agent per server, and a single outbound connection between them.",
};
export default function PlatformPage() {
return (
<>
<section className="rail band band--open">
<span className="tag">Platform</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "19ch" }}>How the pieces fit together.</h1>
<p className="lede">Three moving parts: a control plane you run, an agent on each server, and one outbound connection between them.</p>
<div className="split" style={{ marginTop: "3rem" }}>
<div>
<h2 style={{ fontSize: "var(--s-2)", maxWidth: "18ch" }}>The agent never listens.</h2>
<div className="prose" style={{ marginTop: "1rem" }}>
<p>
Every agent dials out to the control plane over gRPC with TLS. Nothing needs an inbound port, nothing needs a static address, and a machine behind NAT is no different
from one with a public IP.
</p>
<p>
Key state is polled on a 30-second interval, because 30 seconds is fine for access control and polling is simple to reason about. Everything that should not wait
running a step, opening a console, applying updates is pushed down a bidirectional command stream the agent holds open.
</p>
</div>
</div>
<div className="specs specs--flush">
<div className="spec">
<span className="spec__k">POLL</span>
<div>
<h3>SyncKeys, every 30s</h3>
<p>The desired key set for this server. Unchanged state means no disk write at all.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">PUSH</span>
<div>
<h3>Command stream</h3>
<p>Generate a key, run a step, apply updates, update the agent, clean up a workspace.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">REPORT</span>
<div>
<h3>Inventory and checks</h3>
<p>Metrics every 30 seconds, a full hardware snapshot every 15 minutes, and monitor results as they complete.</p>
</div>
</div>
</div>
</div>
</section>
<section className="rail band">
<span className="tag">Write path</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "24ch" }}>The file is never half-written.</h2>
<div className="split split--even" style={{ marginTop: "2rem" }}>
<p className="prose">
The agent computes the desired <code>authorized_keys</code> content, compares it to what is on disk, and stops there if nothing changed. When it does need to write, it writes a
temporary file in the same directory and renames it over the real one. A machine that loses power mid-write keeps the file it had.
</p>
<pre className="code">
<i>// agent poll, simplified</i>
{"\n"}
desired := client.SyncKeys(serverID, token){"\n"}
current := keys.ReadAuthorizedKeys(){"\n\n"}
<b>if</b> !keys.StateChanged(current, desired) {"{"}
{"\n "}
<i>// nothing to do</i>
{"\n "}
<b>return</b> nil{"\n"}
{"}"}
{"\n\n"}
keys.WriteAuthorizedKeys(desired){"\n"}
<i>// write .tmp, os.Rename(), chmod 0600</i>
</pre>
</div>
</section>
<section className="rail band">
<span className="tag">Tenancy and identity</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>Organisations are the boundary.</h2>
<div className="caps">
<article className="cap">
<span className="cap__k">Isolation</span>
<h3>Scoped at the query</h3>
<p>
Every server, key, workflow, monitor and secret belongs to an organisation, and every lookup is filtered by it. Uniqueness constraints are enforced by the database, not by
application logic.
</p>
</article>
<article className="cap">
<span className="cap__k">Roles</span>
<h3>Owner, admin, member</h3>
<p>Members operate the fleet. Admins and owners manage people, identity settings and the secrets read token.</p>
</article>
<article className="cap">
<span className="cap__k">Identity</span>
<h3>Local or OIDC, per organisation</h3>
<p>Sign in with email and password, or connect your own provider. Each organisation configures its own issuer and client.</p>
</article>
<article className="cap">
<span className="cap__k">Sessions</span>
<h3>Server-side, 24 hours</h3>
<p>Cookies carry an opaque identifier and nothing else. Session bodies live in Redis, so losing it signs everyone out and costs no durable data.</p>
</article>
</div>
</section>
<section className="rail band">
<span className="tag">What we do not build</span>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>The scope is the feature.</h2>
<div className="specs" style={{ maxWidth: "70ch" }}>
<div className="spec">
<span className="spec__k">NOT A PROXY</span>
<div>
<h3>We are never in the SSH path</h3>
<p>Vantage assigns keys; your client connects straight to the box. If our control plane is down, your SSH still works.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NO CUSTODY</span>
<div>
<h3>Private keys stay put by default</h3>
<p>Keys generated on a server stay on it unless you explicitly upload the private half, and anything stored is encrypted with a key only your deployment holds.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NO PER-USER</span>
<div>
<h3>Root, not every account</h3>
<p>Vantage manages one file per server. Per-user key management is a different product with a different failure mode.</p>
</div>
</div>
<div className="spec">
<span className="spec__k">NO PLUGINS</span>
<div>
<h3>An agent you can read in an evening</h3>
<p>A few thousand lines of Go with no extension system. Auditability beats extensibility on a binary that runs as root.</p>
</div>
</div>
</div>
</section>
</>
);
}

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