Compare commits

..
19 Commits
Author SHA1 Message Date
mrhid6 1eb8c79623 Merge branch 'feat/shared-module'
Server Deploy / deploy (push) Successful in 2m43s
Shared Go module extraction (spec 0a) and the Org to Instance rename (spec 0b).

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

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

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

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

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

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

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

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

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

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

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

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

Design only. No implementation.

Un-ignores docs/superpowers/ so specs are versioned.
2026-07-24 13:13:47 +01:00
117 changed files with 6686 additions and 1560 deletions
+4 -2
View File
@@ -24,7 +24,8 @@ jobs:
- name: Build and push server image
run: |
IMAGE="${{ vars.DOCKER_HOST }}/${{ github.repository_owner }}/vantage/server:latest"
docker build -t "$IMAGE" -f server/Dockerfile server/
# Root context: server depends on the shared module.
docker build -t "$IMAGE" -f server/Dockerfile .
docker push "$IMAGE"
- name: Build and push web image
@@ -49,5 +50,6 @@ jobs:
- 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/
# Root context: sitesvc depends on the shared module.
docker build -t "$IMAGE" -f sitesvc/Dockerfile .
docker push "$IMAGE"
+2 -1
View File
@@ -2,7 +2,8 @@ node_modules
dist
build
.env
docs
docs/*
!docs/superpowers/
.superpowers
installer/vantage-agent-windows-amd64.exe
installer/*.msi
+5 -1
View File
@@ -1,5 +1,9 @@
//go:build !linux
// Inventory collection is Linux-only. This no-op stands in everywhere else.
//
// The build constraint above is load-bearing: "_other" is not a GOOS suffix, so
// without it this file compiles on Linux too and collides with collect_linux.go.
package inventory
import "github.com/mrhid6/vantage/agent/internal/grpc/pb"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,410 @@
# Spec 3 — Admin Backend
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`)
Ships: with spec 4 (the site it serves). Blocks specs 4 and 5.
## Context
A fourth Go service, `admin/`, owning customers, instances, licences and
subscriptions. It is the only service that holds the signing key.
Its data model separates two things the control plane deliberately does not know
about:
- **Account** — a paying customer. Holds a Paddle customer, a billing email, and
one or more instances.
- **Instance** — one deployment. Cloud instances mirror a control-plane
`Instance` row; self-hosted instances exist only here, because the customer's
database is theirs and we cannot see it.
## Goals
1. Issue, store and re-issue licences, with full history.
2. Inject licences into cloud instances.
3. Serve both staff and customers, with the right things hidden from each.
4. Never be a runtime dependency of a Vantage instance. If admin is down,
every instance keeps working; only purchasing and renewals stop.
## Non-goals
- The UI. Spec 4.
- Paddle. Spec 5. This spec defines the `subscriptions` table and the issuance
functions that spec 5's webhooks call, and nothing more.
- Rebuilding billing management. Card changes, invoices and cancellation go to
Paddle's own customer portal.
## Design
### Module
```
admin/
├── go.mod # replace => ../shared
├── cmd/main.go
└── internal/
├── api/ # gin handlers
├── auth/ # staff, cloud-customer and local-customer sessions
├── db/ # two connections: admin DB and control-plane DB
├── models/ # admin-owned documents
├── licensing/ # issuance, renewal, relink
├── inject/ # control-plane writes
├── mail/ # licence delivery
└── paddle/ # spec 5 lands here
```
Port `8083`. In `deploy/docker-compose.site.yml` only — like sitesvc, admin is
**excluded from the self-hosted deployment**. A self-hosted customer runs
instances, not the licensing authority.
### Two database connections
The service holds two:
- `ADMIN_MONGO_URI` — its own database, `vantage_admin`. Sole owner.
- `CONTROL_MONGO_URI` — the control plane's database, used to write licence
fields onto cloud instance documents and to authenticate cloud customers.
The control-plane connection uses the `Instance` and `User` structs from
`shared` (spec 0a). This is what makes direct writes safe: there is no admin-side
copy of the document shape to drift, which is the coupling hazard sitesvc used to
carry.
Admin's control-plane access is **narrow by construction**: it reads
`instances` and `users`, and it writes exactly three fields on `instances`. The
Mongo credential it is given should be scoped to that where the deployment allows
it. It must never write to any other collection.
### Data model
```go
type Account struct {
ID bson.ObjectID
AccountID string // uuid
Name string
BillingEmail string
PaddleCustomerID string // empty until first checkout
Status string // active | suspended
CreatedAt time.Time
}
type Instance struct {
ID bson.ObjectID
InstanceID string // for cloud: equals the control-plane instance_id
// for self-hosted: the UUID the customer pasted
AccountID string
Name string
Slug string // cloud only; the subdomain label
Deployment string // cloud | self_hosted
Tier string
Status string // awaiting_link | active | lapsed | cancelled
CurrentLicense string // licence ID
RelinkCount int // reset each term
CreatedAt time.Time
}
type License struct {
ID bson.ObjectID
LicenseID string
InstanceID string
AccountID string
Tier string
Deployment string
Limits license.Limits // snapshot
Features []string // snapshot
IssuedAt time.Time
ExpiresAt time.Time
Blob string
SupersededBy string // licence ID, when replaced
IssuedBy string // staff user, "system", or "paddle:<event id>"
Reason string // new | renewal | tier_change | relink | manual
}
type Subscription struct {
ID bson.ObjectID
SubscriptionID string
AccountID string
InstanceID string
PaddleSubscriptionID string
PaddlePriceID string
Tier string
Term string // monthly | annual
Status string // active | past_due | cancelled | awaiting_link
CurrentPeriodEnd time.Time
}
type Plan struct {
Tier string
Name string
Deployment string
Limits license.Limits
Features []string
PaddleProductID string
PaddlePriceIDs map[string]string // "monthly" | "annual"
Active bool
}
```
Collections: `accounts`, `admin_instances`, `licenses`, `subscriptions`,
`plans`, `staff_users`, `customer_users`, `admin_audit`.
Unique indexes: `accounts.account_id`, `admin_instances.instance_id`,
`licenses.license_id`, `subscriptions.paddle_subscription_id`, `plans.tier`,
`staff_users.email`, `customer_users.email`.
`admin_instances.instance_id` unique is load-bearing: it is what stops the same
self-hosted UUID being linked to two accounts.
**Licences are append-only.** A renewal writes a new row and sets
`SupersededBy` on the old one. Nothing is ever edited or deleted. When a support
question arrives about why a customer's instance stopped working on a given
date, the answer is in the table.
`plans` holds tier contents so they change without a deploy, seeded from the
table in spec 1. Every issued licence snapshots the plan, so editing a plan never
changes an existing licence — the same rule as `workflow_runs.steps_snapshot`.
### Issuance
```go
func Issue(ctx, instanceID, tier, term, reason, issuedBy string) (*models.License, error)
```
1. Load the instance and its account.
2. Load the plan for `tier`; refuse if `plan.Deployment != instance.Deployment`.
**This is the check that makes Free cloud-only** — Free's plan is
`deployment: cloud`, so it can never be issued to a self-hosted instance.
3. Build the payload with the instance's UUID bound in, `ExpiresAt` from the term
plus a **3-day grace** so a renewal webhook arriving slightly late does not
create a gap.
4. Sign with `LICENSE_SIGNING_KEY`.
5. Insert the licence row; set `SupersededBy` on the previous one; update
`instance.CurrentLicense` and `instance.Tier`.
6. If cloud, inject. If self-hosted, email the blob and make it downloadable.
7. Write an `admin_audit` entry.
Steps 5 and 6 are not transactional. Order matters: **record first, deliver
second.** A licence recorded but not delivered is recoverable — the customer
downloads it. A licence delivered but not recorded is a support mystery.
### Free tier rule
One Free instance per account, enforced in `Issue`: refuse a second Free instance
for an account that already has one that is not `cancelled`. Additional
instances must be paid.
### Injection
```go
func InjectCloud(ctx, instanceID string, lic *models.License) error
```
Writes `license_blob`, `license_tier`, `license_expiry` onto the control-plane
`instances` document via a single `UpdateOne`. Idempotent, retryable, and safe to
re-run.
Retries three times with backoff; on final failure the licence stays recorded and
`instance.Status` is set to `active` regardless, with the failure logged and
surfaced as a staff alert. A **reconciliation job runs every 15 minutes**,
comparing each cloud instance's `CurrentLicense` against the blob actually stored
in the control plane, and re-injecting on mismatch. That job, not the webhook, is
what guarantees eventual consistency.
The control-plane instance caches licence state for 60 seconds (spec 2), so an
injection takes effect within a minute without a restart.
### Self-hosted linking
The flow, end to end:
```
Customer runs /setup on their own install → instance UUID generated and shown
Customer buys Self Hosted in the admin site → subscription created,
status awaiting_link
Customer pastes the UUID into the admin site → admin_instances row created,
status active
Admin issues the licence with that UUID bound in
Customer downloads the .lic file or copies the blob
Customer pastes it into /settings/license on their install
```
Validation on link: the UUID must parse as a UUID, must not already exist in
`admin_instances`, and must not collide with a cloud instance ID. A duplicate
returns "That instance ID is already linked to an account" without revealing
which — it is a small enumeration surface but there is no reason to leave it
open.
### Relink
A rebuilt server has a new UUID. `POST /api/instances/:id/relink` with the new
UUID:
- Allowed **3 times per term**, `RelinkCount` reset on renewal.
- Updates `admin_instances.instance_id`, issues a replacement licence for the
**remaining term** with `reason: relink`, supersedes the old one.
- The old licence is not revoked — it cannot be, offline verification has no
revocation. It simply no longer matches any UUID the customer controls, and its
binding stops it being useful on a different machine anyway.
- Beyond 3, the endpoint returns a message directing the customer to support, and
staff can relink without limit.
`RelinkCount` is the abuse signal, not the abuse prevention. Its real job is to
put a human in front of the fourth attempt.
### Authentication
Three identities, three paths, one session store (Redis, `admin_session`
cookie, 24h).
**Staff**`staff_users`, local email plus bcrypt. Full access. Created by CLI
only; there is no staff signup.
**Cloud customers** — authenticate against the control plane's `users`
collection with the credentials they already use. Admin looks the user up
by email, checks bcrypt, resolves their control-plane instance, then resolves the
account that owns it.
Two consequences, stated plainly because they are real:
1. A cloud user's control-plane password now also unlocks billing. Any password
change or compromise has a wider blast radius than before.
2. Only users with control-plane role `owner` may sign in to the admin site.
`admin` and `member` are refused. Billing is an owner concern.
Mitigations: rate-limit to 5 attempts per email per 15 minutes and 20 per IP per
hour; log every attempt to `admin_audit`; return an identical error for unknown
email and wrong password.
**Self-hosted customers**`customer_users`, local email plus bcrypt at cost 12,
created during purchase, scoped to one account. Email verification reuses the
pattern sitesvc already proved: 32 random bytes, only the SHA-256 hash stored,
24-hour expiry, TTL index.
A single email address could in principle be both a cloud user and a
self-hosted customer user. `customer_users` is checked first; if it matches, that
identity wins. Documented so the behaviour is chosen rather than emergent.
### API
Staff:
```
GET /api/staff/accounts list, search
POST /api/staff/accounts
GET /api/staff/accounts/:id
GET /api/staff/instances filter by account, deployment, status, expiry
POST /api/staff/instances/:id/issue manual issue or reissue
POST /api/staff/instances/:id/relink no limit
GET /api/staff/licenses full history, filterable
GET /api/staff/plans
PUT /api/staff/plans/:tier
GET /api/staff/audit
GET /api/staff/health/injection reconciliation status and failures
```
Customer:
```
GET /api/account own account and instances
POST /api/instances/link self-hosted UUID link
POST /api/instances/:id/relink rate-limited
GET /api/instances/:id/license current licence metadata
GET /api/instances/:id/license/download .lic file
GET /api/subscriptions status, next renewal
POST /api/billing/portal Paddle portal redirect (spec 5)
```
Every customer handler resolves the account from the session and scopes by it.
The scoping is enforced by a helper every handler calls, not by each handler
remembering — the same deny-by-default reasoning as spec 2's middleware.
### Configuration
| Variable | Required | Notes |
|---|---|---|
| `ADMIN_MONGO_URI` | yes | admin's own database; name read from the URI path, refused if absent |
| `CONTROL_MONGO_URI` | yes | control-plane database, for injection and cloud auth |
| `REDIS_ADDR` | yes | sessions |
| `LICENSE_SIGNING_KEY` | yes | ed25519 private key hex. **Boot fails without it** — a licensing service that cannot sign is worse than one that is down, because it looks healthy |
| `PUBLIC_URL` | yes | for verification and licence links |
| `SMTP_*` | yes | licence delivery |
| `ADMIN_ORIGIN` | yes | CORS allow-list |
| `TRUST_PROXY` | no | only behind a proxy that overwrites `X-Forwarded-For` |
| Paddle variables | spec 5 | |
### Backfill
Licences issued by `lkctl` during the spec 12 period exist only as blobs.
A one-shot `admin backfill --from=blobs.json` parses each with
`license.Parse`, creates the account, instance and licence rows, and marks them
`reason: manual`. Run once when admin goes live.
## Testing
**Issuance:**
1. `Issue` produces a licence that `license.Verify` accepts for that instance.
2. Deployment mismatch (Free plan, self-hosted instance) is refused.
3. A second Free instance for the same account is refused; a third paid one is
allowed.
4. Renewal supersedes the previous licence and leaves it in the table.
5. The issued licence snapshots the plan; editing the plan afterwards does not
change the issued licence.
6. Grace period: `ExpiresAt` is term end plus 3 days.
**Injection:**
7. `InjectCloud` writes all three fields; the control plane then reports `valid`.
8. Injection is idempotent across two calls.
9. Injection failure leaves the licence recorded and flags the instance.
10. The reconciliation job detects a control-plane blob that does not match
`CurrentLicense` and re-injects.
**Linking and relink:**
11. Linking an unknown UUID succeeds; linking one already linked is refused.
12. Relink issues a licence for the *remaining* term, not a fresh full term.
13. The fourth relink in a term is refused for a customer and allowed for staff.
14. `RelinkCount` resets on renewal.
**Auth:**
15. Cloud owner signs in with control-plane credentials; `admin` and `member`
roles are refused.
16. Unknown email and wrong password return identical errors and timing is not a
meaningful oracle.
17. Rate limits trigger at the documented thresholds.
18. Self-hosted customer cannot sign in before verifying their email.
19. A customer requesting another account's instance gets `404`, not `403`
no existence disclosure.
**Scoping:**
20. Every customer endpoint, called with a session for account A against a
resource of account B, returns `404`. Written as a table-driven test over the
route list so a new endpoint that forgets to scope fails the build.
## Verification before merge
1. Full suite green, including the scoping table test (test 20).
2. End to end, cloud: create account → create instance → issue Professional →
confirm the control-plane instance reports `valid` within 60 seconds with no
restart.
3. End to end, self-hosted: run `/setup` on a scratch install, copy the UUID,
link it, issue, download, paste, confirm `valid`.
4. Confirm admin's control-plane credential cannot write to `servers`, `keys` or
any collection other than `instances`.
5. Kill the admin service and confirm every Vantage instance keeps working
entirely normally.
## Risks
| Risk | Mitigation |
|---|---|
| Admin becomes a runtime dependency | Verification step 5; instances verify offline and never call admin |
| Signing key exposure | Single service, single variable, never in an image; rotation path from spec 1 |
| Cloud password now unlocks billing | Owner-only, rate-limited, audited, and stated in the release notes |
| Injection silently fails | Reconciliation every 15 minutes plus a staff health endpoint |
| Admin writes outside its remit in the control plane | Narrow code path; scoped Mongo credential; reviewed on every change |
| Self-hosted UUID squatted by another account | Unique index plus a non-disclosing error |
@@ -0,0 +1,203 @@
# Spec 4 — Admin Site
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 3 (`admin-backend`)
Ships: with spec 3. Can be developed in parallel with spec 5 once spec 3's API
is stable.
## Context
A fifth Next.js app, `adminsite/`, serving two audiences from one codebase:
- **Staff** — internal operators. Accounts, instances, licence history, plan
editing, injection health, audit.
- **Customers** — their own account, instances, licences and subscription state.
They share auth plumbing and a component library but almost no screens. The
split is by route group, so a customer route can never accidentally render a
staff view.
## Goals
1. A customer can buy, link a self-hosted instance, download a licence, and see
when it expires — without contacting anyone.
2. Staff can answer "why did this customer's instance stop working" in one screen.
3. Nothing about the marketing site or the control-plane UI changes.
## Non-goals
- Rebuilding billing management. Card details, invoices, payment methods and
cancellation all deep-link into Paddle's customer portal.
- Server management. This is not a second control plane; there is exactly one
link out to the instance and no data about servers, keys or workflows.
- Public signup for cloud. That stays on the marketing site (moving to admin's
backend in spec 5, but the *form* stays where customers already find it).
## Design
### App
Built exactly like `web/` and `site/`: Next.js 16 App Router, React 18, Tailwind
3, TanStack Query, `output: "standalone"`, `node:26-alpine`, listening on `3000`,
published as `3002`. In `docker-compose.site.yml` only.
`ADMIN_API_URL` is baked in at build time, as `API_URL` is for `web/`. It must be
**browser-reachable** and must appear in the backend's `ADMIN_ORIGIN`. Getting
this wrong is the single most common deployment failure in this repo's history —
`SITE_API_URL` has the same footgun documented in `CLAUDE.md` — so the app
renders an explicit "not connected" state rather than failing silently.
```
adminsite/
├── app/
│ ├── login/
│ ├── signup/ # self-hosted customer account creation
│ ├── verify/
│ ├── (customer)/
│ │ ├── page.tsx # account overview
│ │ ├── instances/[id]/
│ │ ├── instances/link/
│ │ ├── billing/
│ │ └── layout.tsx # customer nav, account guard
│ └── (staff)/staff/
│ ├── page.tsx # operations dashboard
│ ├── accounts/[id]/
│ ├── instances/[id]/
│ ├── licenses/
│ ├── plans/
│ └── layout.tsx # staff nav, staff guard
├── components/
└── lib/
```
Route-group layouts do the guarding. A customer session hitting `/staff/*` gets
redirected, not a 403 page — there is nothing to tell them about.
### Customer screens
**Overview** — the account, its instances as cards. Each card: name, cloud or
self-hosted, tier, licence state, expiry with days remaining, and a link either
to the instance's subdomain (cloud) or to its licence page (self-hosted).
Licence state is colour-coded and blunt: green valid, amber under 14 days, red
expired. An expired card says what still works — "servers and monitors are still
running; changes are disabled" — because that is the first thing a worried
customer wants to know.
**Instance detail** — tier, limits, features, subscription status, next renewal
date. For self-hosted: the linked UUID, a **Download licence** button, the blob
in a copy-to-clipboard box, and step-by-step paste instructions with the target
route named (`Settings → Licence` on their own install). A **Relink** action
showing the remaining allowance ("2 of 3 relinks remaining this term").
**Link an instance** — the self-hosted activation screen. Explains where to find
the UUID (shown on `/setup`, and permanently on `/settings/license`), takes the
paste, validates the format client-side, and on success issues the licence and
lands the customer directly on the download.
The whole flow — buy, link, download, paste — should be completable without
reading documentation. That is the bar for this screen.
**Billing** — subscription list with status and renewal date, plus a button to
Paddle's portal. Deliberately thin.
### Staff screens
**Dashboard** — the operational answers, not vanity metrics: licences expiring
in the next 14 days, subscriptions `past_due`, instances `awaiting_link` for more
than 48 hours, and **failed injections** from the reconciliation job. Each row
links straight to the thing that needs doing.
**Accounts** — searchable by name, email, Paddle customer ID and instance UUID.
Searching by UUID matters: a support email arrives containing a UUID and nothing
else.
**Account detail** — instances, subscriptions, customer users, audit trail.
**Instance detail** — everything about one instance, with the **full licence
history as a timeline**: issued, superseded, renewed, relinked, each with a
timestamp, reason and who did it. This is the screen that answers "why did this
stop working on the 14th". Actions: issue, reissue, relink without limit, and a
live view of the control-plane injection state for cloud instances.
**Licences** — global history, filterable by tier, deployment, expiry window and
issuance reason.
**Plans** — edit limits and features per tier. Two guard rails, because this
screen changes what every future customer gets:
- A confirmation step naming exactly what changes and stating that existing
licences are unaffected until reissued.
- The deployment field is not editable. Moving Free to `self_hosted` would break
the cloud-only rule that spec 1 leans on; changing it is a code review, not a
form field.
**Audit** — every mutating action, filterable.
### Design language
Visually distinct from `web/`. Staff regularly have both open, and a moment of
"which app am I in" before clicking Reissue is worth designing out. Different
accent colour and a persistent environment badge in the header (sandbox or
production, from a build-time flag) — clicking Issue against the wrong Paddle
environment should be hard.
Shared component patterns with `web/` where they exist; this is not a reason to
invent a second design system.
### Error and empty states
- Backend unreachable: a page-level "not connected" state naming
`ADMIN_API_URL`, matching the pattern the marketing site already uses.
- No instances yet: a customer-facing explanation of the two paths — buy cloud,
or buy self-hosted and link.
- `awaiting_link`: a prominent prompt on the overview, since a customer who has
paid and not linked is a customer who has paid for nothing yet.
- Licence download failure: show the blob inline as a fallback so the customer is
never blocked by a file download.
## Testing
Component and integration tests with mocked API responses. The repo has no
frontend test setup today; this is where one starts, scoped to the flows that
lose money or leak data when broken.
1. Customer session on `/staff/*` redirects; staff session reaches it.
2. Instance card renders correctly for each licence state, including expired,
and the expired copy names what still works.
3. Link flow: valid UUID succeeds and lands on download; malformed UUID is caught
client-side; already-linked UUID surfaces the backend's message.
4. Relink shows the remaining allowance and disables at zero with the support
message.
5. Licence download failure falls back to the inline blob.
6. Not-connected state renders when the API is unreachable.
7. Staff dashboard renders each alert category and links to the right resource.
8. Plan edit requires confirmation and shows the "existing licences unaffected"
wording.
9. Instance search by UUID returns the instance.
10. Licence history timeline renders every reason type in order.
## Verification before merge
1. Test suite green.
2. Full manual pass, self-hosted purchase to working licence, using only the UI
and no documentation — timed, and if it takes more than five minutes the flow
needs work.
3. Full manual pass, cloud: buy, confirm the licence appears in the control plane
within a minute, confirm the instance's own settings page agrees.
4. Staff pass: find an account by instance UUID, read its licence history,
reissue, confirm the control plane picks it up.
5. Responsive check at mobile width — a customer hit by an expiry email will open
this on a phone.
6. `docker build` from the repo root succeeds and the image runs with
`ADMIN_API_URL` baked in.
## Risks
| Risk | Mitigation |
|---|---|
| `ADMIN_API_URL` misconfigured at build | Explicit not-connected state; documented alongside the existing `SITE_API_URL` footgun |
| Staff action taken against the wrong environment | Persistent environment badge; confirmation on destructive actions |
| Customer confused by the self-hosted flow | Step-by-step link screen; five-minute bar in verification |
| Customer session reaching staff data | Route-group guards plus backend scoping (spec 3, test 20). Two layers, because one is not enough for this |
@@ -0,0 +1,353 @@
# Spec 2 — Instance Licensing and Enforcement
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a, spec 0b, spec 1 (`licensing-core`)
Ships: independently, with licenses issued by hand via `lkctl`. No admin site
needed.
## Context
Spec 1 defines what a license is. This spec makes the control plane hold one,
act on it, and let a self-hosted operator paste one in.
The guiding rule: **an expired license must never break a running fleet.** Agents
keep their keys, monitors keep watching, alerts keep firing. What stops is
growth and change. A customer whose card fails should be inconvenienced, not
paged at 3am because their monitoring went dark when Vantage decided to sulk.
## Goals
1. A license lives on the instance document and is verified on read.
2. Enforcement is deny-by-default: a new mutating route is gated because of where
it is mounted, not because someone remembered.
3. Degraded mode is obvious in the UI and reversible by pasting a valid license.
4. Self-hosted operators get an instance UUID they can hand to the admin site.
## Non-goals
- Issuing licenses. `lkctl` (spec 1) or the admin backend (spec 3).
- Any outbound network call. Verification is offline, permanently.
- Per-user or per-role licensing. The unit is the instance.
## Design
### Instance identity
Every install already has an `Instance` document with an `InstanceID` UUID. For
cloud instances this is created by signup; for self-hosted it is created by
`/setup`.
Change to `/setup`: after bootstrapping the first instance and its owner, the
setup page **displays the instance UUID** with a copy button and the text that
it is needed to activate a license. It is also shown permanently on
`/settings/license`.
No new identifier is invented. The instance UUID is the licensing identity.
### Storage
`shared/models.Instance` gains:
```go
LicenseBlob string `bson:"license_blob,omitempty" json:"-"`
LicenseTier string `bson:"license_tier,omitempty" json:"license_tier,omitempty"`
LicenseExpiry *time.Time `bson:"license_expiry,omitempty" json:"license_expiry,omitempty"`
```
The blob is authoritative. `LicenseTier` and `LicenseExpiry` are a denormalised
cache for listing and for the admin site's queries, rewritten from the verified
payload every time a blob is accepted. Nothing reads them for enforcement.
`LicenseBlob` is `json:"-"`. It is not a secret in the confidentiality sense —
it is signed public data — but there is no reason to spray it through API
responses.
### Runtime state
```go
type State struct {
Status license.State // valid | expired | invalid
Reason string
Tier string
ExpiresAt *time.Time
Limits license.Limits
Features map[string]bool
}
```
Resolved by `services.LicenseState(instanceID) State`, cached for 60 seconds
alongside the existing instance cache and invalidated immediately when a blob is
stored.
Three inputs, in precedence order:
1. `Instance.LicenseBlob`.
2. `VANTAGE_LICENSE` environment variable, used **only when the instance has no
stored blob**. This lets an automated self-hosted deployment ship a license
without a human pasting one. A blob stored through the UI always wins
afterwards, so an operator is never locked out by a stale environment value.
3. Neither → `Status: invalid`, `Reason: no_license`.
The verifier is called with `InstanceID` from the instance document and
`Deployment` from `VANTAGE_DEPLOYMENT` (`cloud` on our infrastructure,
`self_hosted` everywhere else, defaulting to `self_hosted`). The default matters:
an operator who removes the variable gets the stricter mode, not the looser one.
`invalid` and `expired` degrade identically. They differ only in the message.
### Enforcement
Three layers, deliberately separate because they answer different questions.
**Layer 1 — mutation gate.** A gin middleware `RequireActiveLicense` mounted on
the `/api` group, applying to every request whose method is not `GET` or `HEAD`.
```go
api := r.Group("/api", auth.RequireSession(), services.RequireActiveLicense())
```
Non-`valid``403 {"error":"license_required","state":"expired","reason":"..."}`.
Mounting at the group means **a route added tomorrow is gated by default**. That
is the whole point of putting it here rather than on individual handlers.
Explicit exemptions, allow-listed by path because they must work in degraded
mode:
| Route | Why |
|---|---|
| `POST /api/license` | Pasting a valid license is how you recover |
| `POST /auth/*` | Login and logout are outside `/api` already; listed for clarity |
| `DELETE` on any resource | Deleting is how you get back under a limit |
| `POST /api/servers/:id/apply-updates` | Security patching must never be paywalled |
The `DELETE` exemption deserves emphasis: a customer downgraded to Free with 10
servers must be able to remove 7 of them. Blocking deletes would trap them.
**Layer 2 — feature gate.** `RequireFeature(name)` on the route groups that need
it:
- `console``POST /api/console/connect`, `GET /api/console/tunnel`
- `oidc``GET,PUT /api/instance/oidc`
Missing feature → `403 {"error":"feature_unavailable","feature":"console"}`.
OIDC needs care: `/auth/oidc/start` and `/auth/oidc/callback` are unauthenticated
and outside `/api`. They check the feature directly and, if unavailable, redirect
to `/login?error=oidc_unavailable` rather than returning JSON. **Existing OIDC
sessions are not terminated** — losing the feature stops new SSO logins, it does
not evict people mid-session.
**Layer 3 — limits.** Enforced in the service layer, because a limit needs a
count that middleware does not have:
| Limit | Checked in |
|---|---|
| `max_servers` | `services.CreateServer` / `POST /api/servers/new` |
| `max_secret_groups` | `services.CreateSecretGroup` |
| `max_channels` | `services.CreateChannel` |
`-1` means unlimited. Over limit → `403 {"error":"limit_exceeded","limit":"max_servers","current":3,"max":3}`.
Counts are of live rows: revoked assignments and deleted servers do not count.
**Over-limit instances are never truncated.** A Professional instance with 20
servers that lapses to Free keeps all 20 running; it simply cannot add a 21st.
Deleting resources is always permitted. Silently disabling a customer's servers
because their card expired is not a behaviour this system will have.
### Background work in degraded mode
This is where "read-only" needs to be specific, because these paths do not go
through gin at all.
| Subsystem | Degraded behaviour |
|---|---|
| **Monitor scheduler** | **Keeps running.** Checks execute, incidents open, notifications fire. |
| Monitor create/edit/delete | Blocked by layer 1 (delete exempted). |
| Workflow runner | New runs blocked by layer 1. **In-flight runs finish** rather than being killed mid-step — a half-run workflow is worse than a completed one. |
| Agent `SyncKeys` | Returns the existing desired key set unchanged. Nothing is torn off disk. New assignments cannot be created, so nothing changes anyway. |
| Agent registration | A **new** agent registering against an over-limit instance is refused with a clear message; existing agents re-register freely. |
| Inventory, heartbeat, update reporting | Unaffected. |
| `ApplyUpdatesCmd` | Allowed. Security patching is not gated. |
| ESO secrets read (`GET /api/secrets/:group/values`) | **Allowed.** It is a `GET`, and breaking a Kubernetes cluster's secret sync over a billing state is disproportionate. |
| Log retention sweep, offline sweep | Unaffected. |
Keeping monitors alive is a deliberate reversal of a stricter earlier draft. It
is the single most important line in this spec: **billing state must not take
away a customer's ability to know their infrastructure is on fire.**
### API
```
GET /api/license any authenticated user
POST /api/license owner only
```
`GET` returns:
```json
{
"instance_id": "…",
"state": "valid",
"reason": "",
"tier": "professional",
"expires_at": "2027-07-24T00:00:00Z",
"days_remaining": 365,
"limits": { "max_servers": -1, "max_secret_groups": -1, "max_channels": -1 },
"features": { "console": true, "oidc": true },
"usage": { "servers": 12, "secret_groups": 4, "channels": 2 },
"source": "stored"
}
```
`usage` is included so the UI can render "12 of 3 servers" honestly when an
instance is over its limit, rather than pretending.
`POST` takes `{"blob": "..."}`, verifies with the instance's own ID and
deployment mode, and on success stores the blob, refreshes the cache, and writes
an audit event. On failure it returns `400` with the specific reason:
| Reason | Message |
|---|---|
| `bad_signature` | This licence key is not valid. Check it was copied in full. |
| `deployment_mismatch` | This licence is for Vantage Cloud and cannot be used on a self-hosted install. |
| `instance_mismatch` | This licence was issued for a different instance. Your instance ID is `<uuid>`. |
| `expired` | This licence expired on `<date>`. |
An **expired** blob is still stored if it is otherwise valid, so the UI can show
what expired and when. An **invalid** blob is rejected and the previous one kept.
Rate-limited to 10 attempts per instance per hour. There is no oracle here worth
protecting, but an unbounded verify endpoint is an unbounded CPU endpoint.
### Frontend
`useLicense()` hook over `GET /api/license`, cached by TanStack Query and
invalidated after a successful paste.
- **Banner, persistent, top of every page** when `state != valid`:
- `expired` — "Your Vantage licence expired on `<date>`. Your servers and
monitors are still running, but changes are disabled until it is renewed."
with a link to the admin site.
- `invalid` / `no_license` — "This instance has no valid licence. Add one in
Settings → Licence."
- **Warning banner** in the final 14 days of a valid term, dismissible per
session.
- **Gated features render disabled with an upgrade tooltip, not hidden.** A
customer cannot buy what they cannot see, and a feature that vanishes reads as
a bug.
- **Limit indicators** on the servers, secrets and channels list pages: "3 of 3
servers used" with the create button disabled at the cap.
- `/settings/license`: current state, tier, expiry, limits with live usage, the
instance UUID with a copy button, and a textarea plus file upload for a new
blob. Owner-only; other roles see the state read-only.
### Grandfathering existing tenants
Migration `0005_grandfather_licenses`, cloud only, guarded on
`VANTAGE_DEPLOYMENT == "cloud"`:
For every instance with no `license_blob`, issue a Professional license expiring
**one year** from the migration date and store it.
The migration cannot sign — the server has no private key and, per spec 1, no
signing code. So the blobs are **generated ahead of time with `lkctl`** and
supplied to the migration through `VANTAGE_GRANDFATHER_BLOBS`, a JSON map of
instance ID to blob. The migration stores what it is given, verifies each blob
against its instance before storing, and logs any instance it had no blob for.
Clumsy, and correct. The alternative is putting a signing key in the control
plane, which is the thing this design most wants to avoid.
Self-hosted installs are not grandfathered. On upgrade they land in `no_license`
and read-only until an operator pastes a key — which is the intended behaviour
for a paid product, and is why the release notes must lead with it.
## Testing
**Unit, no database:**
1. `State` resolution precedence: stored blob wins over `VANTAGE_LICENSE`;
environment used when no blob; neither → `no_license`.
2. Feature map construction from the payload's `Features` slice.
3. Limit comparison with `-1`, with zero, and with a count exactly at the cap.
**Middleware, with a stub state:**
4. `GET` passes in every state.
5. `POST`/`PUT`/`DELETE` pass when `valid`, fail `403` when `expired` and when
`invalid` — except `DELETE`, which passes in all states.
6. `POST /api/license` passes when `expired` (the recovery path).
7. `POST /api/servers/:id/apply-updates` passes when `expired`.
8. `RequireFeature("console")` passes with the feature, `403`s without it.
9. **Coverage test:** enumerate every registered route and assert that every
non-`GET` route is either behind `RequireActiveLicense` or on the exemption
allow-list. This test is what stops layer 1 rotting as routes are added.
**Service layer, against MongoDB:**
10. `CreateServer` at the cap → `limit_exceeded`; one below → succeeds.
11. Over-limit instance can still `DELETE` a server, and can create again once
back under the cap.
12. Deleted and revoked rows do not count toward limits.
**Degraded background behaviour:**
13. Monitor scheduler executes checks for an instance with an expired license.
14. An incident opened during degraded mode still dispatches notifications.
15. `SyncKeys` for an expired instance returns the same key set as before expiry.
16. A new agent registering against an over-limit instance is refused; an
existing agent re-registers successfully.
17. A workflow run in flight when the license expires completes its remaining
steps.
**API:**
18. `POST /api/license` with a valid blob stores it and flips state to `valid`.
19. Each rejection reason returns its own message and leaves the stored blob
untouched.
20. An expired-but-well-formed blob is stored and reported as `expired`.
21. Non-owner `POST``403`.
**Migration:**
22. `0005` stores and verifies supplied blobs, skips instances that already have
one, logs instances with no blob supplied, and is a no-op when
`VANTAGE_DEPLOYMENT != "cloud"`.
## Verification before merge
1. Full test suite green, including the route-coverage test (test 9).
2. Manual pass on a scratch instance: issue a Professional license with `lkctl`,
paste it, confirm everything works. Issue one expiring in 60 seconds, wait,
confirm the banner appears, mutations `403`, **monitors keep firing**, and
pasting a fresh license restores normal operation without a restart.
3. Manual pass on the Free tier: confirm the 3-server cap, that console and OIDC
are visibly disabled with upgrade tooltips, and that a 4th server is refused
with a clear message.
4. Confirm a cloud-issued Free license is rejected on a `self_hosted` install
with `deployment_mismatch`.
5. Confirm a license issued for another instance is rejected with
`instance_mismatch` and the message shows the correct local UUID.
## Rollout
1. Generate grandfather blobs with `lkctl` for every existing cloud instance.
2. Deploy with `VANTAGE_GRANDFATHER_BLOBS` set; migration `0005` runs.
3. Verify every cloud instance reports `valid`, Professional, one year out.
4. Unset the variable on the next deploy — it is single-use.
5. Release notes for self-hosted must state plainly that upgrading requires a
licence key, and how to get one.
## Risks
| Risk | Mitigation |
|---|---|
| A mutating route added later without a gate | Route-coverage test (test 9) fails the build |
| Customer locked out and unable to recover | `POST /api/license` and all `DELETE`s exempt from the gate |
| Existing cloud tenants degrade on deploy | Migration 0005, verified before the traffic switch |
| Over-limit customer trapped | Deletes always allowed; existing resources never truncated |
| Clock wrong on a self-hosted host | `Verify` warns on a future `IssuedAt`; documented in the licence settings page |
| Monitoring lost on billing failure | Explicitly designed out — the scheduler ignores licence state |
@@ -0,0 +1,240 @@
# Spec 0b — Org to Instance Rename
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a (`shared-module`)
Ships: independently, before any licensing code
## Context
The licensing model separates two concepts that the codebase currently conflates
under one word:
- **Account** — a paying customer. Lives only in the admin control plane
(spec 3). The control plane never learns about it.
- **Instance** — one deployment of Vantage: its own subdomain, its own users,
its own servers, keys, workflows, monitors and secrets. One license attaches
to one instance.
Today's control-plane `Org` **is** an Instance. An Account may hold several,
some cloud and some self-hosted, and the self-hosted ones have no row in the
cloud database at all.
Keeping the name `Org` would leave the control plane using a word that means
something different in the admin site, in Paddle, and in every support
conversation. This spec renames it everywhere, including on disk.
This is the highest-risk change in the programme: `org_id` is the tenant
isolation key on every document in every collection. It is done alone, before
anything else, so that nothing else is in flight when it deploys.
## Goals
1. `Instance` is the only word for a tenant, in code, API, UI and database.
2. No document is lost and no tenant scoping is weakened.
3. The migration is reversible.
## Non-goals
- Any behaviour change. Same routes' semantics, same permissions, same data.
- Introducing Accounts. The control plane never gets them.
- Touching the agent. It talks gRPC and has no concept of a tenant.
## Design
### Naming map
| Today | After |
|---|---|
| collection `orgs` | `instances` |
| collection `org_oidc` | `instance_oidc` |
| field `org_id` (all collections) | `instance_id` |
| `models.Org` | `models.Instance` |
| `Org.OrgID` | `Instance.InstanceID` |
| `User.OrgID`, `Settings.OrgID`, every `OrgID` field | `InstanceID` |
| `services/orgs.go`, `GetOrg`, `CreateOrg`, `ListOrgIDs`, `CountOrgs`, `FirstOrg`, `AdoptOrg`, `GetOrgBySlug` | `services/instances.go`, `GetInstance`, `CreateInstance`, … |
| `services/org_oidc.go` | `services/instance_oidc.go` |
| `auth/orghost.go` | `auth/instancehost.go` |
| `/api/org/users`, `/api/org/oidc` | `/api/instance/users`, `/api/instance/oidc` |
| `shared/provision.CreateOrg`, `RollbackOrg` | `CreateInstance`, `RollbackInstance` |
| session field `org_id` | `instance_id` |
| `GET /auth/me` response `org_id` / `org` | `instance_id` / `instance` |
| UI copy "Organisation" | "Instance" |
Reserved slugs gain no new entries here, but note `admin` is already reserved,
which the admin site relies on later.
### Collections carrying `org_id`
All of: `servers`, `keys`, `assignments`, `users`, `org_oidc`, `settings`,
`secrets`, `workflows`, `workflow_steps`, `workflow_runs`, `monitors`,
`incidents`, `monitor_rollups`, `notification_channels`, `console_sessions`,
`audit_logs`, plus `orgs` itself. `migrations` does not carry one.
`site_pending_signups` does not carry `org_id`, but its `org_name` field becomes
`instance_name` for consistency; it is sitesvc-private so this is free.
The migration must derive this list from a constant in code, not from a
hand-written list in a runbook, so that a collection added between design and
deploy is not silently missed:
```go
var scopedCollections = []string{ /* the list above */ }
```
A boot-time assertion (spec 2 onwards) checks that no collection outside this
list contains an `org_id` field. Cheap insurance against a future collection
being added without being renamed.
### Migration `0004_org_to_instance`
Recorded in `migrations` like the existing three. Runs after
`0003_missed_org_scopes`.
**The migration only renames. It never deletes and never drops.** A bad deploy
is recovered by running the inverse rename, not by restoring a backup.
Steps, in order:
1. **Guard.** If collection `instances` already exists and `orgs` does not, the
migration has already run against this database by an earlier binary; record
the marker and return. Idempotency matters because the marker write and the
data work are not in one transaction.
2. **Rename collections.** `orgs``instances`, `org_oidc``instance_oidc`,
via `adminCommand{renameCollection}`. Fails loudly if the target exists.
3. **Rename the field.** For each collection in `scopedCollections`:
`UpdateMany({org_id: {$exists: true}}, {$rename: {"org_id": "instance_id"}})`.
Record `matched` and `modified` per collection in the log.
4. **Verify.** For each collection, assert
`CountDocuments({org_id: {$exists: true}}) == 0` and
`CountDocuments({instance_id: {$exists: true}}) == totalCount`. Any mismatch
aborts before the marker is written, leaving the migration to retry.
5. **Indexes.** Drop and recreate indexes that name `org_id` in their key spec:
unique `settings.instance_id`, the ESO token-hash index, and any compound
scoping indexes. Unique `instances.slug` and `users.email` are unaffected by
the field rename but are re-declared idempotently.
6. **Write the marker.**
Steps 24 are not atomic across collections. Mongo multi-document transactions
would require a replica set, which is not guaranteed for self-hosted installs.
Instead the migration is written to be **safely re-runnable**: `$rename` on a
document that has already been renamed matches nothing, and the collection
rename is guarded in step 1.
Rollback, if ever needed, is the same code with the rename reversed, shipped as
a one-shot command rather than a migration — deliberately manual, because the
only reason to run it is a decision to revert the release.
### Version skew
`sitesvc` and `server` write the same documents. A skew where one writes
`org_id` and the other reads `instance_id` creates tenants that are invisible to
the application — the exact failure `CLAUDE.md` warns about.
After spec 0a both read the shape from `shared`, so the skew window is a
deployment-ordering problem rather than a code-drift problem:
- Both images are built from the same commit and deployed together.
- The migration runs from the `server` container at boot, as the existing three
do.
- `sitesvc` at boot asserts that collection `instances` exists and refuses to
start otherwise, with the message
`instances collection not found; deploy the control plane first`. Failing to
start is strictly better than provisioning into a collection nobody reads.
The self-hosted deployment runs no sitesvc, so it sees only the server change.
### API and frontend
REST route renames are **breaking**, but every consumer is first-party (`web/`)
and ships in the same release. No compatibility aliases — a permanent dual path
in the tenant-scoping layer is worse than a coordinated release.
`web/` changes: the API client's paths, the `useMe` shape, all UI copy from
"Organisation" to "Instance", and the settings route `/settings/org`
`/settings/instance`.
`site/` marketing copy changes where it says "organisation" about a tenant. Where
it means the customer, it becomes "account" — that word now has a specific
meaning and the marketing site is the first place a customer meets it.
## Testing
**No automated tests.** Decision taken 2026-07-24, consistent with spec 0a.
This is the change where that costs the most: it moves the tenant isolation key
across 17 collections, and a mistake orphans a customer's entire fleet rather
than breaking a build. The compensating controls are therefore not optional, and
the implementation plan makes each a mandatory step:
1. **Dry run against a restored copy** before the code is even committed —
migrate a `mongorestore`d duplicate of production and read the per-collection
rename counts.
2. **Idempotency by hand** — run the dry run twice; the second must complete
with no error and nothing left to rename.
3. **Interrupted-run recovery by hand** — rename `orgs` manually, then run the
migration; it must complete and leave every document carrying `instance_id`.
4. **Count comparison against a production snapshot** — record every
collection's document count before and after; any difference stops the
release.
5. **Per-tenant isolation comparison** — for three real tenants, count rows in
`servers`, `keys`, `workflows`, `monitors`, `secrets` and `audit_logs` by
`org_id` before and by `instance_id` after. Identical, or the release stops.
This is the check that proves tenant isolation survived.
6. **Stale-field sweep** — assert no collection anywhere still holds an
`org_id`.
7. **Rollback rehearsal** — migrate a third copy, run `rename-rollback`, confirm
the counts return to baseline and the pre-release binary boots against it.
Deploying without having done this is not permitted.
8. **Boot guard, both directions** — sitesvc must refuse an unmigrated database
and start normally against a migrated one.
`AssertNoScopedCollectionMissed` runs at every boot and is fatal. With no test
suite it is the standing protection against a future collection being added
without being added to `ScopedCollections`.
## Verification before merge
Run against a **restored production snapshot**, not a synthetic database:
1. Record `db.getCollectionNames()` and per-collection `countDocuments()` before.
2. Run the migration.
3. Assert every count is identical afterwards.
4. Assert `instances.countDocuments()` equals the old `orgs.countDocuments()`.
5. Pick three real tenants; run the same scoped query before (by `org_id`) and
after (by `instance_id`) and confirm identical result sets. This is the test
that proves tenant isolation survived.
6. Boot the server against the migrated snapshot; log in as a real user; confirm
servers, keys, workflows, monitors and secrets all list correctly.
7. Boot sitesvc against the migrated snapshot; complete a signup end to end.
8. Boot sitesvc against an **un**migrated snapshot; confirm it refuses to start
with the expected message.
## Rollout
1. Take a database backup. Not optional — this is the one change where the
inverse rename is the recovery path and the backup is the second.
2. Deploy `server`, `web`, `site` and `sitesvc` from one commit, together.
3. Server boots, migration runs, marker recorded.
4. Watch for the sitesvc guard message; if it appears, sitesvc started first and
will restart cleanly.
Expect a short window during the server restart where the API is unavailable.
Agents are unaffected: they reconnect, and no gRPC message carries a tenant ID.
## Risks
| Risk | Mitigation |
|---|---|
| Partial migration leaves mixed field names | Step 4 verification aborts before the marker; migration is re-runnable |
| A collection missed from the list | List is a code constant plus a completeness test plus a boot-time assertion |
| sitesvc deployed before server | Boot guard refuses to start |
| An index still keyed on `org_id` | Step 5 drops and recreates; verification includes an index listing diff |
| A hard-coded `org_id` string outside the model layer | `grep -rn '"org_id"' server/ sitesvc/ shared/` must return only the migration file after the change |
| Frontend missed a renamed route | Full manual pass over every route in the UI before release |
## Follow-on
With `Instance` established, spec 1 (`licensing-core`) can define a license
payload that binds to `instance_id` without inventing a word the codebase does
not use.
@@ -0,0 +1,294 @@
# Spec 1 — Licensing Core
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 0a (`shared-module`), spec 0b (`instance-rename`)
Ships: independently. Adds a package and a CLI; changes no running behaviour.
## Context
Licenses are **offline-verified signed blobs**. A Vantage server checks a
signature and an expiry date and asks nobody's permission. That choice buys
self-hosted installs that work in air-gapped networks and a control plane with no
licensing availability dependency.
It costs revocation. Once issued, a license is valid until it expires, whatever
Paddle later says. Every other decision in the programme follows from accepting
that: Self Hosted is annual-only so the unenforceable window is bounded, and
cancellation takes effect at term end rather than immediately (spec 5).
This spec defines the payload, the signing and verification, and a CLI to issue
licenses by hand. It deliberately lands before the admin site so that specs 1+2
together give working licensing with no new service to operate.
## Goals
1. One struct, in `shared`, read identically by the verifier and the issuer.
2. Verification that needs no network, no clock sync beyond a rough one, and no
configuration.
3. A hand-issuance path good enough to run production on until spec 3 lands.
## Non-goals
- Storing licenses. Spec 2 owns the instance document; spec 3 owns issuance
history.
- Deciding tier contents. Tiers are data; the values in this spec are the
initial seed, and spec 3's `plans` table becomes their home.
- Any phone-home, revocation list or online check. There is none, anywhere, by
design.
## Design
### Package
`shared/license/`, inside the module created by spec 0a:
```
shared/license/
├── license.go # License, Limits, feature constants
├── sign.go # Sign, build-tagged out of the server binary
├── verify.go # Verify, Parse
├── keys.go # trustedPublicKeys
└── license_test.go
```
Uses `github.com/hyperboloide/lk` (ed25519, base32 encoding).
### Payload
```go
package license
type License struct {
ID string `json:"id"` // uuid, for support and audit
InstanceID string `json:"instance_id"` // the instance this license is bound to
AccountID string `json:"account_id"` // admin-side customer, informational
InstanceName string `json:"instance_name"` // display only
Tier string `json:"tier"` // "free" | "professional" | "self_hosted"
Deployment string `json:"deployment"` // "cloud" | "self_hosted"
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"`
Limits Limits `json:"limits"`
Features []string `json:"features"`
}
type Limits struct {
MaxServers int `json:"max_servers"` // -1 means unlimited
MaxSecretGroups int `json:"max_secret_groups"`
MaxChannels int `json:"max_channels"`
}
const (
FeatureConsole = "console" // browser SSH/RDP/VNC
FeatureOIDC = "oidc" // per-instance single sign-on
)
const (
TierFree = "free"
TierProfessional = "professional"
TierSelfHosted = "self_hosted"
DeploymentCloud = "cloud"
DeploymentSelfHosted = "self_hosted"
)
```
`InstanceID` is **always populated**. There is no unbound license: the
self-hosted purchase flow (spec 4) links the instance UUID before the license is
issued, so binding happens at signing time. This removes the claim endpoint, the
best-effort phone-home and the multi-claim reconciliation that an unbound design
would have needed.
**The server never branches on `Tier`.** It reads `Limits` and `Features` only.
`Tier` exists for display, support and analytics. Adding a tier, or changing what
a tier includes, must never require a server release.
### Tier seed values
Recorded here as the initial contents of spec 3's `plans` table. Snapshotted into
each license at issue, so changing the table never rewrites an issued license —
the same principle as `workflow_runs.steps_snapshot`.
| | Free | Professional | Self Hosted |
|---|---|---|---|
| `deployment` | `cloud` | `cloud` | `self_hosted` |
| `max_servers` | 3 | -1 | -1 |
| `max_secret_groups` | 1 | -1 | -1 |
| `max_channels` | 1 | -1 | -1 |
| `console` | no | yes | yes |
| `oidc` | no | yes | yes |
| billing term | monthly, £0 | monthly or annual | **annual only** |
Free is cloud-only. A self-hosted install can never hold a valid Free license
because Free is only ever signed with `deployment: "cloud"`, and verification
rejects a deployment mismatch. There is no server-side flag to edit.
### Signing
```go
//go:build !noSign
func Sign(l License, privateKeyHex string) (string, error)
```
Marshals to canonical JSON, signs with lk, returns the base32 blob.
`Sign` is excluded from the server binary with a build tag. The server has no
reason to hold signing code and there is no reason to ship it into a customer's
data centre.
The private key lives in `LICENSE_SIGNING_KEY` on the issuing side only — the
CLI now, the admin backend from spec 3. It is never in the repo, never in an
image, never in the control plane's environment.
### Verification
```go
type VerifyOpts struct {
InstanceID string // required: the verifier's own instance
Deployment string // required: "cloud" or "self_hosted"
Now time.Time // injectable for tests
}
type Result struct {
License License
State State // Valid, Expired, Invalid
Reason string
}
const (
StateValid State = "valid"
StateExpired State = "expired"
StateInvalid State = "invalid"
)
func Verify(blob string, opts VerifyOpts) Result
```
Checks, in order, stopping at the first failure:
1. Blob decodes and the signature verifies against one of `trustedPublicKeys`.
Failure → `Invalid`, reason `bad_signature`.
2. `l.Deployment == opts.Deployment`. Failure → `Invalid`, reason
`deployment_mismatch`. This is the check that makes Free cloud-only.
3. `l.InstanceID == opts.InstanceID`. Failure → `Invalid`, reason
`instance_mismatch`.
4. `opts.Now.Before(l.ExpiresAt)`. Failure → `Expired`.
5. Otherwise `Valid`.
**`Expired` and `Invalid` are distinct states and the caller treats them
differently in messaging** (spec 2), even though both degrade the instance the
same way. A customer whose card failed and a customer who pasted the wrong blob
need different words.
`Parse(blob) (License, error)` verifies the signature only, ignoring binding and
expiry. Used by the admin site to display a license and by support to inspect a
blob a customer has emailed in. Never used for enforcement.
Clock skew: no tolerance is applied. Terms are a month or a year; a server whose
clock is wrong by enough to matter has bigger problems, and a tolerance window is
a thing to get wrong. `Verify` logs at warn level if `IssuedAt` is in the future,
which is the signal that a clock is badly off.
### Key management
```go
// trustedPublicKeys is ordered. Index 0 is the current signing key.
// To rotate: prepend the new key, ship a server release, then reissue.
// Remove a retired key only after every license signed with it has expired.
var trustedPublicKeys = []string{
"<hex ed25519 public key>",
}
```
A slice from day one even though it holds one entry, because retrofitting a
single-key verifier into a multi-key one during an incident is not a thing to
plan for.
Public keys are compiled in. They are not configurable, because a configurable
trust root is a licensing bypass: a self-hosted operator could point it at a
keypair they generated.
Key generation is a documented one-off:
```
go run ./shared/license/cmd/lkgen keypair
```
prints a private key hex for the vault and a public key hex to paste into
`keys.go`. The private key is stored in a password manager and in the admin
service's environment. **If it is lost, no new licenses can be issued for any
existing customer without a server release.** Back it up in two places.
### CLI issuer
`shared/license/cmd/lkctl`, built only for internal use:
```
lkctl keypair
lkctl issue --instance-id=<uuid> --instance-name="Acme" \
--tier=professional --deployment=cloud \
--term=1y [--account-id=<id>] [--out=acme.lic]
lkctl inspect <file-or-blob>
```
`issue` reads `LICENSE_SIGNING_KEY`, applies the tier seed values from a table
compiled into the CLI, and prints the blob. `--term` accepts `1m`, `1y` or an
explicit `--expires=RFC3339`.
This is the production issuance path until spec 3 ships. It is kept afterwards
for support and disaster recovery — if the admin service is down and a customer's
license expires, a blob can still be cut by hand.
Issued blobs from `lkctl` are not recorded anywhere. Spec 3 backfills its
`licenses` table from `inspect` output when it takes over.
## Testing
`shared/license` is pure and needs no database, so this suite is fast and
thorough. Written test-first.
1. Round trip: `Sign` then `Verify` returns `Valid` with an identical payload.
2. Tampering: flip one character of the blob → `Invalid`, `bad_signature`.
3. Tampering with intent: re-sign a payload with a *different* keypair →
`Invalid`. This is the test that proves an attacker cannot mint licenses.
4. Expiry: `ExpiresAt` one second in the past → `Expired`. One second in the
future → `Valid`.
5. Deployment mismatch: a Free (`cloud`) license verified with
`Deployment: "self_hosted"``Invalid`, `deployment_mismatch`.
6. Instance mismatch: correct signature, different `InstanceID``Invalid`,
`instance_mismatch`.
7. Check order: a blob that is both expired *and* instance-mismatched reports
`instance_mismatch`, not `Expired`. Order is part of the contract because the
reason drives the message.
8. Multi-key: a license signed with `trustedPublicKeys[1]` verifies. One signed
with a key not in the slice does not.
9. `Parse` returns the payload for an expired and for a mismatched license, and
errors for a bad signature.
10. Unicode and long instance names survive the round trip.
11. Golden blob: a fixture blob checked into the repo, signed with a **test-only**
keypair, must keep verifying. This catches an accidental change to the
canonical JSON encoding, which would silently invalidate every issued
license in the field.
Test 11 matters more than it looks. The encoding is part of the wire format.
## Verification before merge
1. `go test ./shared/license/...` passes, including the golden fixture.
2. `lkctl keypair``lkctl issue``lkctl inspect` round trips at the command
line.
3. `go build -tags noSign ./server/...` succeeds and
`go tool nm` on the resulting binary shows no `license.Sign` symbol.
4. The production keypair is generated, the private half stored in two places,
and the public half committed in `keys.go`.
## Risks
| Risk | Mitigation |
|---|---|
| Signing key lost | Documented two-location backup; generation is a one-off with an explicit checklist |
| Signing key leaked | Rotation path exists from day one: prepend key, release, reissue. Retire the old key once its licenses expire |
| Canonical encoding changes | Golden fixture test |
| Signing code shipped to customers | Build tag plus a symbol check in verification |
| No revocation | Accepted and documented. Bounded by term length; Self Hosted is annual-only |
@@ -0,0 +1,271 @@
# Spec 5 — Paddle Billing
Date: 2026-07-24
Status: Design approved, not implemented
Depends on: spec 3 (`admin-backend`)
Ships: after spec 3. Can be developed in parallel with spec 4.
## Context
Paddle is merchant of record: it owns checkout, tax, invoices, dunning and the
customer billing portal. This spec connects Paddle's subscription lifecycle to
the licence issuance functions spec 3 defines, and moves cloud signup off
sitesvc.
The central constraint, restated because every table below follows from it:
**licences are offline-verified, so nothing Paddle says can revoke one early.**
Cancellation takes effect when the licence expires. Self Hosted is annual-only to
bound that window; the alternative — a customer holding a valid key for eleven
months after cancelling a monthly plan — is not acceptable.
## Goals
1. A catalog in Paddle sandbox, promotable to production by configuration alone.
2. Webhooks that issue and renew licences reliably, including under retries and
out-of-order delivery.
3. Cloud signup owned by one service instead of two.
## Non-goals
- Building any part of billing Paddle already provides.
- Usage-based or metered pricing. Tiers are flat.
- Proration logic. Paddle handles money; we react to the resulting subscription
state.
## Design
### Catalog
Three products, created in **sandbox** first. Production is a configuration
change: the same `plans` rows carry different `paddle_product_id` and
`paddle_price_ids`, selected by `PADDLE_ENV`.
| Product | Prices | Notes |
|---|---|---|
| Vantage Free | monthly, £0 | Yes, a real £0 subscription. It gives every account a Paddle customer, a lifecycle, and an upgrade path with no special-case code. |
| Vantage Professional | monthly, annual | Cloud |
| Vantage Self Hosted | **annual only** | No monthly price exists, so the offline-revocation window is at most a year |
**No price ID is ever hard-coded.** They live in `plans.paddle_price_ids` and are
edited through the staff UI. A price change in Paddle is a data edit, not a
deploy.
`custom_data` on every checkout carries `{ account_id, instance_id, tier }`. This
is what lets a webhook route without a lookup table, and it is why the
self-hosted flow creates the instance record *before* checkout completes.
### Checkout
Paddle Checkout, overlay mode, in the admin site.
**Cloud upgrade** — instance exists, `instance_id` in `custom_data`, existing
Paddle customer reused.
**Self-hosted purchase** — the instance does not exist yet. Order:
```
Customer creates an admin-site account (verified email)
Account row created, then an admin_instances row with status awaiting_link
and a generated placeholder instance record
Checkout opened with account_id and that instance row's id in custom_data
subscription.created fires → subscription recorded, status awaiting_link,
NO licence issued
Customer pastes their install's UUID → instance_id set, status active
→ licence issued and delivered
```
The instance row exists before payment so the webhook has something to attach to.
The licence is not issued until the UUID is known, because a licence with no
instance to bind to cannot be signed — spec 1 has no unbound licence.
A customer who pays and never links has a subscription and no licence. Spec 4's
staff dashboard flags `awaiting_link` older than 48 hours, and a reminder email
goes out at 24 hours and 72 hours. This is the most likely place for a paying
customer to get stuck, so it gets active chasing rather than a support queue.
### Webhooks
`POST /api/paddle/webhook`, signature-verified with `PADDLE_WEBHOOK_SECRET`.
An unsigned or badly signed request is rejected `401` and logged — never
processed.
**Idempotency is mandatory.** Paddle retries. Every event ID is recorded in
`paddle_events` with a unique index before processing; a duplicate returns `200`
without acting. `200` on duplicates matters — returning an error would make
Paddle retry a message we have already handled, forever.
| Event | Action |
|---|---|
| `subscription.created` | Record the subscription. Cloud: issue and inject. Self-hosted: leave `awaiting_link`, issue nothing. |
| `subscription.updated` | Tier or term changed: issue a replacement licence at the new tier, supersede the old. Cloud injects; self-hosted emails a new blob and flags the site. Reflects Paddle's resulting state; no proration maths here. |
| `subscription.canceled` | Mark `cancelled`. **No licence action.** The current licence runs to expiry, then the instance degrades per spec 2. |
| `subscription.past_due` | Mark `past_due`, notify the customer, flag for staff. Licence untouched. Dunning is Paddle's job; ours is not to punish a retryable card failure. |
| `transaction.completed` where the transaction is a subscription renewal | Issue the next term's licence, supersede, inject or email. Reset `RelinkCount`. |
| `transaction.payment_failed` | Record for staff visibility. No licence action. |
| `customer.updated` | Sync `billing_email` onto the account. |
Out-of-order delivery is handled by making every handler a function of the
subscription's *current* state as reported in the event payload, rather than of
the transition. An `updated` arriving before its `created` creates the
subscription row and proceeds.
Renewal licences are issued with a **3-day grace** past the period end (spec 3),
so a webhook delayed by hours never produces a gap in coverage.
**Webhook failures must be visible.** Every failed handler writes to
`admin_audit` and appears on the staff dashboard. A licence that silently failed
to issue is a customer who paid and got nothing.
### Cancellation, stated plainly
When a customer cancels:
- Paddle stops billing at period end.
- We issue no further licences.
- Their current licence keeps working until it expires — up to a month for
Professional monthly, up to a year for Self Hosted.
- On expiry the instance degrades per spec 2: monitors keep running, changes stop.
This is documented in the terms and shown on the cancellation confirmation
screen, because a customer who cancels and sees their instance keep working
should understand why rather than assume the cancellation failed.
### Signup migration off sitesvc
Cloud signup currently lives in sitesvc: `site_pending_signups`, a verification
email, and provisioning on link click. It now needs to also create an Account, a
Paddle customer, a Free subscription and a licence.
**Signup moves to the admin backend.** The form stays on the marketing site where
customers find it, but it posts to admin instead of sitesvc. sitesvc keeps the
contact form only.
The reason is the one `CLAUDE.md` already names: provisioning logic duplicated
across services drifts. Spec 0a removed the second copy; adding signup to admin
while leaving it in sitesvc would create a third.
New flow, preserving every property of the current one:
```
Marketing site form → POST /api/signup on admin
→ pending record, password bcrypt cost 12, token 32 random bytes,
only the SHA-256 hash stored, 24h expiry, TTL index
→ verification email
Link opened → FindOneAndDelete the pending record (atomic, before provisioning)
→ shared.CreateInstance + shared.CreateUser in the control plane
→ Account created
→ Paddle customer created, Free subscription created
→ Free licence issued and injected
→ redirect to APP_LOGIN_URL with {slug} filled in
```
Properties that must survive, verified by test:
- Nothing written to `instances` or `users` until the link is opened.
- `FindOneAndDelete` before provisioning, so a double-clicked link cannot create
two instances.
- Instance rollback if the owner insert fails, refusing to delete an instance
that has users.
- Re-submitting for the same address replaces the pending record.
- Rate limited to 3 signups per IP per hour, plus the honeypot field.
Two failure modes are new, because provisioning now spans two systems:
- **Paddle customer creation fails** — the instance and user are already created.
Complete the signup, record the account with an empty `PaddleCustomerID`, issue
the Free licence anyway, and flag for staff. A new customer must never be
blocked from signing in by a billing-system hiccup.
- **Licence issuance fails** — the instance exists with no licence and is
read-only. Flagged for staff, and the 15-minute reconciliation job (spec 3)
retries. The customer can log in and sees the licence banner.
Both resolve toward "the customer gets in", because a signup that half-fails
silently is worse than either outcome.
sitesvc changes: signup, verify, `site_pending_signups` and the provisioning
calls are deleted. `SITE_API_URL` gains a sibling for the admin endpoint, or the
marketing site posts signup to `ADMIN_API_URL` directly — the latter, so the two
form targets are explicit rather than implied.
### Configuration
| Variable | Required | Notes |
|---|---|---|
| `PADDLE_ENV` | yes | `sandbox` or `production`; selects which price IDs the plans table serves |
| `PADDLE_API_KEY` | yes | server-side API |
| `PADDLE_CLIENT_TOKEN` | yes | browser checkout; baked into the admin site build |
| `PADDLE_WEBHOOK_SECRET` | yes | signature verification. Boot fails without it — an unverified webhook endpoint is an endpoint anyone can issue licences through |
| `APP_LOGIN_URL` | yes | moved from sitesvc; `{slug}` template |
### Cutover
Signup migration is the only user-visible switch:
1. Deploy admin with signup enabled; sitesvc still serving its own.
2. Point the marketing site's form at admin. Deploy.
3. Let sitesvc's outstanding pending signups expire naturally — 24 hours — while
its verify endpoint stays live. **Do not delete the collection until it is
empty**, or someone's verification link breaks.
4. Deploy sitesvc with signup removed.
## Testing
**Webhooks:**
1. Each event type produces its documented action against a mock Paddle payload.
2. Replaying an event ID is a no-op returning `200`.
3. A bad signature is rejected `401` and processes nothing.
4. `subscription.updated` before `subscription.created` creates the subscription
and applies the update.
5. `subscription.canceled` issues nothing and leaves the current licence intact.
6. `past_due` leaves the licence intact and flags the account.
7. Renewal issues the next term, supersedes, resets `RelinkCount`, and the new
`ExpiresAt` is period end plus 3 days.
8. A handler failure writes to `admin_audit` and surfaces on the dashboard.
**Checkout:**
9. `custom_data` round-trips account, instance and tier through to the webhook.
10. Self-hosted checkout leaves the instance `awaiting_link` with no licence.
11. Linking after checkout issues the licence.
**Signup:**
12. Nothing is written to `instances` or `users` before the link is opened.
13. A double-clicked verification link creates exactly one instance.
14. Owner-insert failure rolls the instance back; rollback refuses an instance
with users.
15. Re-submitting replaces the pending record and invalidates the earlier link.
16. Rate limit and honeypot both reject.
17. Paddle customer creation failure still completes signup and issues the Free
licence.
18. Licence issuance failure still lets the user log in, showing the banner.
19. Expired pending records are dropped by the TTL index.
## Verification before merge
1. Full suite green.
2. Against Paddle **sandbox**, end to end for each tier: checkout with a test
card, confirm the licence is issued, confirm the instance reports `valid`.
3. Trigger a sandbox renewal and confirm the next term's licence arrives and is
injected.
4. Cancel in sandbox and confirm the licence keeps working to expiry, then the
instance degrades correctly — monitors still running.
5. Replay every webhook from Paddle's dashboard and confirm no duplicate licences
are created.
6. Full signup end to end through admin, then confirm the new user can log into
their control-plane instance and sees a valid Free licence.
7. Confirm sitesvc's pending-signup collection is empty before its signup code is
removed.
## Risks
| Risk | Mitigation |
|---|---|
| Duplicate licences from webhook retries | Unique index on event ID, checked before processing |
| Webhook missed entirely | 15-minute reconciliation job (spec 3) compares subscription state against issued licences |
| Cancellation not enforceable until expiry | Accepted, bounded by term; Self Hosted annual-only; stated in terms and on the cancellation screen |
| Signup cutover breaks in-flight verification links | Staged cutover; sitesvc's verify stays live until its collection is empty |
| Sandbox price IDs reaching production | `PADDLE_ENV` selects them from the plans table; environment badge in the admin site |
| Webhook endpoint unauthenticated | Signature verification mandatory; boot fails without the secret |
| Customer pays and never links | Reminder emails at 24h and 72h, staff dashboard alert at 48h |
@@ -0,0 +1,286 @@
# Spec 0a — Shared Module Extraction
Date: 2026-07-24
Status: Design approved, not implemented
Ships: independently. No dependency on any other licensing spec.
## Context
Vantage is three independent Go modules: `server`, `sitesvc`, `agent`. There is no
root `go.mod` and no `go.work`.
`sitesvc` writes into the same MongoDB collections the control plane reads, but
cannot import the control plane, so it carries hand-copied duplicates:
- `sitesvc/internal/models/models.go``Org` and `User` mirrored field for field
- `sitesvc/internal/provision/provision.go``Slugify`, `ReservedSlugs`,
`MinSlugLength`, `MaxSlugLength`, `BcryptCost`, slug-collision rules
Both files carry comments saying they must be changed in lockstep with the
control plane, and `CLAUDE.md` names the hazard explicitly: nothing enforces the
match. **The duplication has already drifted.** The control plane's `CreateOrg`
resolves slug collisions with an inline `fmt.Sprintf("%s-%d", base, i)` loop,
while sitesvc exposes the same rule as a separate `NextSlug(base, attempt)`
helper. They currently agree by luck, not by construction.
The licensing programme adds a fourth service (`admin`) that writes the license
blob onto the same tenant document. Adding a third copy of these rules is not
acceptable. This spec removes the duplication before any licensing code is
written.
This spec is a **pure refactor**. No database document changes. No behaviour
changes. Names stay as they are today (`Org`, `org_id`) — renaming happens in
spec 0b, deliberately kept separate so that a failed deploy has one suspect
rather than two.
## Goals
1. One authoritative definition of every document shape written by more than one
service.
2. One authoritative definition of provisioning rules (slug, bcrypt cost,
creation, rollback).
3. `sitesvc` keeps its independence from `server` — it depends on `shared`, not
on the control plane. The original design intent survives; only the copying
dies.
4. The agent is untouched.
## Non-goals
- Renaming anything. That is spec 0b.
- Moving control-plane-only models. `workflow.go`, `monitor.go`, `key.go`,
`server.go`, `secret.go`, `assignment.go`, `channel.go`, `console_session.go`,
`audit.go`, `org_oidc.go` stay in `server/internal/models`. Only the control
plane touches them, and hoisting them would make `shared` a dumping ground.
- Merging the repo into a single module.
## Design
### Module layout
```
vantage/
├── go.work # NEW: server, sitesvc, shared (NOT agent)
├── shared/ # NEW module: github.com/mrhid6/vantage/shared
│ ├── go.mod
│ ├── models/
│ │ ├── org.go # Org
│ │ ├── user.go # User, RoleOwner/RoleAdmin/RoleMember, ValidRole
│ │ └── settings.go # Settings, AlertSettings, EmailSettings, SecretsSettings
│ ├── provision/
│ │ ├── slug.go # Slugify, BaseSlug, NextSlug, ReservedSlugs, limits
│ │ ├── org.go # CreateOrg
│ │ ├── user.go # CreateUser, BcryptCost
│ │ └── rollback.go # RollbackOrg
│ └── indexes/
│ └── indexes.go # EnsureCoreIndexes
├── server/ # replace => ../shared
├── sitesvc/ # replace => ../shared
└── agent/ # untouched
```
`go.work`:
```
go 1.26
use (
./shared
./server
./sitesvc
)
```
Each consumer's `go.mod` also carries an explicit replace:
```
require github.com/mrhid6/vantage/shared v0.0.0
replace github.com/mrhid6/vantage/shared => ../shared
```
Both are needed. `go.work` makes editors, `go test ./...` and local tooling work
across modules. The `replace` directives make Docker builds work whether or not
`go.work` is present, and stop `go build` outside the workspace from silently
trying to resolve `shared` from the network.
`shared` depends only on `go.mongodb.org/mongo-driver/v2`,
`golang.org/x/crypto/bcrypt` and `github.com/google/uuid`. It must not import
gin, redis, guac or anything else from the control plane's tree — that is what
keeps sitesvc small.
### What moves
**`shared/models`** — the three documents written by more than one service:
| Type | From | Written by |
|---|---|---|
| `Org` | `server/internal/models/org.go` | server, sitesvc, later admin |
| `User` + role constants + `ValidRole` | `server/internal/models/user.go` | server, sitesvc |
| `Settings` and its sub-structs | `server/internal/models/settings.go` | server today; admin reads it later |
`Settings` moves now rather than later because spec 3's admin service reads it,
and moving it later would mean a second round of import churn across both
services.
`PendingSignup` does **not** move. Only sitesvc writes `site_pending_signups`,
and the control plane does not know the collection exists.
**`shared/provision`** — the rules, promoted from private helpers to a real API:
```go
const (
MinSlugLength = 3
MaxSlugLength = 40
BcryptCost = 12
)
var ReservedSlugs = map[string]bool{ /* www, api, app, admin, auth, install, static, _next, default */ }
func Slugify(name string) string
func BaseSlug(name string) (string, error) // validates length + reserved
func NextSlug(base string, attempt int) string
// CreateOrg resolves a free slug and inserts. The caller supplies the
// collection handle so shared does not own a Mongo connection.
func CreateOrg(ctx context.Context, db *mongo.Database, name string) (*models.Org, error)
func CreateUser(ctx context.Context, db *mongo.Database, orgID, email, password, role string) (*models.User, error)
// RollbackOrg deletes an org only if it has no users. Refuses otherwise.
func RollbackOrg(ctx context.Context, db *mongo.Database, orgID string) error
```
`shared.CreateOrg` becomes the single implementation. The control plane's
`services.CreateOrg` shrinks to a wrapper that calls it and then runs
`SeedDefaultSteps` — seeding stays in the server, because `shared` must not know
about workflow steps. sitesvc calls `shared.CreateOrg` directly and does not
seed, which is the behaviour it has today.
Note on the slug loop: it is count-then-insert and therefore racy. It is safe
only because of the unique index on `orgs.slug`. `CreateOrg` must keep handling
`mongo.IsDuplicateKeyError` and returning a clean error — moving the code must
not lose that. Document the reliance in a comment at the loop.
**`shared/indexes`** — `EnsureCoreIndexes(ctx, db)` declares the unique indexes
on `users.email` and `orgs.slug`. Both services call it at boot; creating an
existing index is a no-op. These indexes are a security property, not an
optimisation (see `CLAUDE.md`: `GetUserByEmail` does an unscoped `FindOne`, so
duplicates would break the OIDC cross-org guard), so the shared version is
**fatal on failure** for both callers.
Server-only index builders (`EnsureSettingsIndexes`, `EnsureSecretIndexes`,
`EnsureWorkflowIndexes`) stay in the server and keep their current
fatal/warn behaviour.
### What is deleted
- `sitesvc/internal/models/models.go` — reduced to `PendingSignup` only
- `sitesvc/internal/provision/` — deleted entirely
- `sitesvc/internal/store/store.go` — org/user creation replaced by calls into
`shared/provision`; pending-signup storage stays
### Docker and CI
Both Go Dockerfiles currently build with the module directory as context:
```dockerfile
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build ... ./cmd
```
A `replace => ../shared` cannot resolve from that context. Build contexts move
to the repo root:
```dockerfile
WORKDIR /src
COPY shared/go.mod shared/go.sum ./shared/
COPY server/go.mod server/go.sum ./server/
RUN cd server && go mod download
COPY shared/ ./shared/
COPY server/ ./server/
ARG VERSION=dev
RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
```
The two-stage copy keeps the dependency-download layer cached, which is the
reason the current Dockerfiles are written the way they are.
`.gitea/workflows/server-deploy.yml` must set `context: .` and
`file: server/Dockerfile` (and likewise for sitesvc) for the two Go images. The
`web` and `site` image builds are unaffected.
`agent-release.yml` is untouched. The agent is not in the workspace, has no
`replace`, and cross-compiles exactly as it does today.
### Error handling
No new error paths. `shared/provision` returns the same error strings the two
callers produce today so that API responses do not change. The one place to be
careful is wording: sitesvc says "organisation" and the control plane says
"organization". `shared` standardises on **"organisation"**; the control plane's
two error strings change spelling. This is user-visible in API error text and is
called out here so it is a decision rather than an accident.
## Testing
**No automated tests.** Decision taken 2026-07-24: the repo has no Go test suite
and one is not being started here. Verification is by compiler, `grep`, and
running both services end to end.
That places the whole weight on three manual checks, which the implementation
plan makes mandatory steps rather than suggestions:
1. **bson tag diff**`diff` the `bson:"…"` tags of each moved struct against
the originals. A changed tag orphans production data silently, and this is
the only thing that catches it.
2. **Slug behaviour walkthrough** — a throwaway `main` printing `Slugify`,
`BaseSlug` and `NextSlug` output for a fixed input table, compared against
expected output recorded in the plan.
3. **End-to-end agreement** — sign up through sitesvc against a scratch
database, open the verification link, then log into the control plane with
those credentials. This is the check that proves the two services still agree
about the documents they share. If it passes, the refactor worked.
Plus `grep` assertions that exactly one definition of `Slugify` and
`ReservedSlugs` survives repo-wide, and that no struct under `sitesvc/` carries
a `bson:"org_id"` tag.
## Verification before merge
Evidence required, not assertions:
1. `go build ./...` succeeds in `shared`, `server` and `sitesvc`.
2. `go vet ./...` clean in all three.
3. `docker build -f server/Dockerfile .` and `docker build -f sitesvc/Dockerfile .`
both succeed from the repo root.
4. `grep -r "org_id" sitesvc/` returns hits only in `PendingSignup` context and
`shared` imports — no local struct redefinitions.
5. End-to-end against a scratch database: sitesvc signup form → verification link
→ org and owner created → that owner logs into the control plane
successfully. This is the test that proves the two services still agree.
6. The agent still builds for `linux/amd64`, `linux/arm64` and `windows/amd64`.
## Rollout
Single release. `server` and `sitesvc` images must be deployed together — a skew
is harmless here (documents are unchanged) but there is no reason to split it.
No database migration. No downtime.
## Risks
| Risk | Mitigation |
|---|---|
| Docker context change breaks CI | Verified locally by building both images from root before pushing |
| Behaviour drift while moving `CreateOrg` | Unit tests written against current behaviour first, then the move |
| `shared` accumulating control-plane concerns | Explicit non-goals above; keep its `go.mod` dependency list to three entries and review any addition |
| Error-string spelling change | Called out as a decision; grep the web UI for hard-coded matches on the old strings |
## Follow-on
Spec 0b (`instance-rename`) becomes a rename inside one module plus its
consumers, rather than a rename across three independent copies. That is the
whole reason this spec goes first.
+67
View File
@@ -0,0 +1,67 @@
# Vantage Licensing Programme — Spec Index
Seven specs, designed 2026-07-24. Build in this order.
| # | Spec | Ships alone | Blocks |
|---|---|---|---|
| 0a | [shared-module](2026-07-24-shared-module-design.md) | yes | everything |
| 0b | [instance-rename](2026-07-24-instance-rename-design.md) | yes | 1, 2, 3 |
| 1 | [licensing-core](2026-07-24-licensing-core-design.md) | yes | 2, 3 |
| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | yes, with `lkctl`-issued licences | — |
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | no | 4, 5 |
| 4 | [admin-site](2026-07-24-admin-site-design.md) | no | — |
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | no | — |
4 and 5 can run in parallel once 3 lands.
## The shape
```
Account (admin only)
├── Instance 1 cloud vantage.hostxtra.co.uk/<slug> licence auto-injected
├── Instance 2 cloud licence auto-injected
└── Instance 3 self-hosted customer's own deployment licence pasted by hand
```
The control plane knows only **Instance**. Accounts exist solely in the admin
service, because a self-hosted instance has no row in the cloud database at all.
## Decisions that everything else follows from
**Licences are offline-verified signed blobs.** ed25519 via
`github.com/hyperboloide/lk`, public key compiled into the server, no phone-home
anywhere. This buys air-gapped self-hosting and means no Vantage instance ever
depends on the licensing service being up. It costs revocation: a licence is
valid until it expires whatever Paddle later says. Self Hosted is annual-only to
bound that window.
**Every licence is bound to one instance UUID.** Self-hosted customers link their
UUID before the licence is signed, so there is no unbound licence and no claim
protocol.
**Expiry degrades, it does not break.** Monitors keep executing, alerts keep
firing, agents keep their keys, in-flight workflow runs finish. Mutations stop.
Deletes and OS-update application stay open so a customer is never trapped
over-limit or unpatched.
**Tiers are data, not code.** The server reads `Limits` and `Features` and never
branches on tier name. Tier contents live in the admin `plans` table and are
snapshotted into each issued licence, so editing a plan never rewrites history —
the same rule as `workflow_runs.steps_snapshot`.
| | Free | Professional | Self Hosted |
|---|---|---|---|
| deployment | cloud only | cloud | self-hosted |
| max servers | 3 | unlimited | unlimited |
| max secret groups | 1 | unlimited | unlimited |
| max channels | 1 | unlimited | unlimited |
| console | no | yes | yes |
| OIDC | no | yes | yes |
| term | monthly, £0 | monthly or annual | annual only |
Free is cloud-only by construction: it is only ever signed with
`deployment: "cloud"`, and verification rejects a deployment mismatch. There is
no server-side flag to edit. One Free instance per account.
**Existing cloud tenants** are grandfathered to Professional, one year out, by
migration `0005`.
+7
View File
@@ -0,0 +1,7 @@
go 1.26.4
use (
./server
./shared
./sitesvc
)
+16
View File
@@ -0,0 +1,16 @@
cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//uf3IB011ls=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
github.com/cncf/xds/go v0.0.0-20240318125728-8a4994d93e50/go.mod h1:5e1+Vvlzido69INQaVO6d87Qn543Xr6nooe9Kz7oBFM=
github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0=
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE=
+12 -7
View File
@@ -1,17 +1,22 @@
# Build stage
#
# Context is the repository root, not server/, because server depends on the
# shared module through a replace directive.
FROM golang:1.26 AS builder
WORKDIR /app
WORKDIR /src
# Download dependencies first (layer cache)
COPY go.mod go.sum ./
RUN go mod download
# Manifests first so the dependency layer caches independently of source edits.
COPY shared/go.mod shared/go.sum ./shared/
COPY server/go.mod server/go.sum ./server/
RUN cd server && go mod download
# Copy source and build
COPY . .
COPY shared/ ./shared/
COPY server/ ./server/
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
RUN cd server && CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.Version=${VERSION}" -o /vantage-server ./cmd
# Runtime stage
FROM scratch
+31 -26
View File
@@ -19,9 +19,6 @@ 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)")
}
@@ -31,19 +28,13 @@ func main() {
}
log.Println("connected to MongoDB")
if err := services.EnsureAuthIndexes(); err != nil {
log.Fatalf("failed to ensure auth indexes: %v", err)
}
// Migrations 0001 to 0003 still speak the pre-rename shape (orgs, org_id),
// so they must run before 0004 renames everything underneath them.
if err := services.RunMigrations(); err != nil {
log.Fatalf("migration failed: %v", err)
}
// 0002 must precede 0003: 0003 can create a "default" org, which pushes
// 0002 into its ambiguous multi-org branch.
if err := services.MigrateSettingsOrg(); err != nil {
log.Fatalf("settings org migration failed: %v", err)
}
@@ -51,13 +42,31 @@ func main() {
log.Fatalf("missed org scope migration failed: %v", err)
}
// 0004 renames orgs to instances. It must run BEFORE the index builders:
// EnsureAuthIndexes creates instances.slug, which would create an empty
// instances collection and make 0004 refuse to rename onto it.
migCtx, migCancel := context.WithTimeout(context.Background(), 10*time.Minute)
migErr := services.MigrateOrgToInstance(migCtx, db.Database)
migCancel()
if migErr != nil {
log.Fatalf("instance rename migration failed: %v", migErr)
}
assertCtx, assertCancel := context.WithTimeout(context.Background(), 30*time.Second)
assertErr := services.AssertNoScopedCollectionMissed(assertCtx, db.Database)
assertCancel()
if assertErr != nil {
log.Fatalf("scoped collection check failed: %v", assertErr)
}
if err := services.EnsureAuthIndexes(); err != nil {
log.Fatalf("failed to ensure auth indexes: %v", err)
}
if err := services.EnsureSecretIndexes(); err != nil {
log.Printf("warning: failed to ensure secret indexes: %v", err)
}
if err := services.EnsureSettingsIndexes(); err != nil {
log.Fatalf("failed to ensure settings indexes: %v", err)
}
@@ -66,14 +75,14 @@ func main() {
log.Printf("warning: failed to ensure workflow indexes: %v", err)
}
if orgIDs, err := services.ListOrgIDs(); err != nil {
log.Printf("warning: failed to list orgs for default step seeding: %v", err)
if instanceIDs, err := services.ListInstanceIDs(); err != nil {
log.Printf("warning: failed to list instances for default step seeding: %v", err)
} else {
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)
for _, instanceID := range instanceIDs {
if created, updated, err := services.SeedDefaultSteps(instanceID); err != nil {
log.Printf("warning: failed to seed default steps for instance %s: %v", instanceID, err)
} else {
log.Printf("default steps seeded for org %s: %d created, %d updated", orgID, created, updated)
log.Printf("default steps seeded for instance %s: %d created, %d updated", instanceID, created, updated)
}
}
}
@@ -86,7 +95,6 @@ func main() {
}
log.Println("connected to Redis")
go func() {
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
@@ -97,17 +105,14 @@ func main() {
}
}()
go func() {
if err := grpcserver.StartGRPC(9090); err != nil {
log.Fatalf("gRPC server error: %v", err)
}
}()
monitorsched.Start(context.Background())
r := gin.New()
r.Use(gin.Recovery())
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{SkipPaths: []string{"/api/console/tunnel"}}))
+87
View File
@@ -0,0 +1,87 @@
// Command rename-rollback reverses migration 0004.
//
// Run it only as part of a decision to revert the release that introduced the
// instance rename. It renames instance_id back to org_id and restores the two
// collection names. Like the migration, it only renames — it deletes no
// documents.
//
// rename-rollback -uri mongodb://host:27017 -db vantage -confirm
package main
import (
"context"
"flag"
"log"
"time"
"github.com/mrhid6/vantage/server/internal/services"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func main() {
uri := flag.String("uri", "mongodb://localhost:27017", "MongoDB URI")
dbName := flag.String("db", "vantage", "database name")
confirm := flag.Bool("confirm", false, "required; refuses to run without it")
flag.Parse()
if !*confirm {
log.Fatal("refusing to run without -confirm")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
client, err := mongo.Connect(options.Client().ApplyURI(*uri))
if err != nil {
log.Fatalf("connect: %v", err)
}
defer client.Disconnect(ctx)
db := client.Database(*dbName)
// Drop indexes keyed on instance_id first, for the same reason the forward
// migration drops the org_id ones: a unique index treats the missing field
// as null and rejects the second document the rename touches.
for _, c := range services.ScopedCollections {
if err := services.DropIndexesKeyedOn(ctx, db, c, "instance_id"); err != nil {
log.Fatalf("%v", err)
}
}
for _, c := range services.ScopedCollections {
res, err := db.Collection(c).UpdateMany(ctx,
bson.M{"instance_id": bson.M{"$exists": true}},
bson.M{"$rename": bson.M{"instance_id": "org_id"}},
)
if err != nil {
log.Fatalf("rename instance_id in %s: %v", c, err)
}
if res.ModifiedCount > 0 {
log.Printf("%s: reverted %d document(s)", c, res.ModifiedCount)
}
}
for _, r := range []struct{ from, to string }{
{"instances", "orgs"},
{"instance_oidc", "org_oidc"},
} {
cmd := bson.D{
{Key: "renameCollection", Value: *dbName + "." + r.from},
{Key: "to", Value: *dbName + "." + r.to},
}
if err := client.Database("admin").RunCommand(ctx, cmd).Err(); err != nil {
log.Printf("rename %s to %s: %v (continuing)", r.from, r.to, err)
continue
}
log.Printf("renamed collection %s to %s", r.from, r.to)
}
// Remove the marker so a redeployed new binary re-runs the migration.
if _, err := db.Collection("migrations").DeleteOne(ctx, bson.M{"_id": "0004_org_to_instance"}); err != nil {
log.Printf("clear migration marker: %v", err)
}
log.Println("rollback complete")
}
+12 -10
View File
@@ -1,6 +1,6 @@
module github.com/mrhid6/vantage/server
go 1.26
go 1.26.4
require (
github.com/coreos/go-oidc/v3 v3.18.0
@@ -8,7 +8,8 @@ require (
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.20.1
github.com/wwt/guac v1.3.2
go.mongodb.org/mongo-driver/v2 v2.2.2
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
golang.org/x/oauth2 v0.36.0
google.golang.org/grpc v1.64.0
)
@@ -26,32 +27,33 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/gorilla/websocket v1.4.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/klauspost/compress v1.17.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mrhid6/vantage/shared v0.0.0
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sirupsen/logrus v1.4.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240521202816-d264139d666e // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/mrhid6/vantage/shared => ../shared
+16 -18
View File
@@ -35,8 +35,6 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -47,8 +45,8 @@ github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvK
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -94,8 +92,8 @@ github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
@@ -103,8 +101,8 @@ github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfS
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.mongodb.org/mongo-driver/v2 v2.2.2 h1:9cYuS3fl1Xhqwpfazso10V7BHQD58kCgtzhfAmJYz9c=
go.mongodb.org/mongo-driver/v2 v2.2.2/go.mod h1:qQkDMhCGWl3FN509DfdPd4GRBLU/41zqF/k8eTRceps=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
@@ -112,20 +110,20 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -133,16 +131,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+5 -5
View File
@@ -19,7 +19,7 @@ func registerChannelRoutes(g *gin.RouterGroup) {
}
func listChannels(c *gin.Context) {
channels, err := services.ListChannels(auth.OrgID(c))
channels, err := services.ListChannels(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -37,7 +37,7 @@ func createChannel(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"})
return
}
created, err := services.CreateChannel(auth.OrgID(c), &ch)
created, err := services.CreateChannel(auth.InstanceID(c), &ch)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -73,7 +73,7 @@ func updateChannel(c *gin.Context) {
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 {
if err := services.UpdateChannel(auth.InstanceID(c), c.Param("id"), upd); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -81,7 +81,7 @@ func updateChannel(c *gin.Context) {
}
func deleteChannel(c *gin.Context) {
if err := services.DeleteChannel(auth.OrgID(c), c.Param("id")); err != nil {
if err := services.DeleteChannel(auth.InstanceID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -89,7 +89,7 @@ func deleteChannel(c *gin.Context) {
}
func testChannel(c *gin.Context) {
if err := services.TestChannel(auth.OrgID(c), c.Param("id")); err != nil {
if err := services.TestChannel(auth.InstanceID(c), c.Param("id")); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
+12 -23
View File
@@ -13,9 +13,6 @@ import (
"github.com/wwt/guac"
)
func consoleConnect(c *gin.Context) {
var body struct {
ServerID string `json:"server_id" binding:"required"`
@@ -30,13 +27,13 @@ func consoleConnect(c *gin.Context) {
return
}
srv, err := services.GetServer(auth.OrgID(c), body.ServerID)
srv, err := services.GetServer(auth.InstanceID(c), body.ServerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
sess, err := services.CreateConsoleSession(auth.OrgID(c), body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP())
sess, err := services.CreateConsoleSession(auth.InstanceID(c), body.ServerID, body.Protocol, body.KeyID, actorFromCtx(c), c.ClientIP())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -48,20 +45,20 @@ func consoleConnect(c *gin.Context) {
}
if (body.Protocol == "rdp" || body.Protocol == "vnc") && (body.RDPUsername != "" || body.RDPPassword != "") {
if err := services.StashConsoleRDPCreds(auth.OrgID(c), sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil {
if err := services.StashConsoleRDPCreds(auth.InstanceID(c), sess.SessionID, body.RDPUsername, body.RDPPassword); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if body.Protocol == "ssh" {
if err := services.SetConsoleSSHUser(auth.OrgID(c), sess.SessionID, body.SSHUsername); err != nil {
if err := services.SetConsoleSSHUser(auth.InstanceID(c), sess.SessionID, body.SSHUsername); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
services.LogEvent(auth.OrgID(c), "console.opened", actorFromCtx(c), srv.ServerID, "",
services.LogEvent(auth.InstanceID(c), "console.opened", actorFromCtx(c), srv.ServerID, "",
"console session opened ("+body.Protocol+")")
c.JSON(http.StatusOK, gin.H{
@@ -71,8 +68,6 @@ func consoleConnect(c *gin.Context) {
})
}
func queryIntDefault(r *http.Request, key string, def int) int {
v, err := strconv.Atoi(r.URL.Query().Get(key))
if err != nil || v <= 0 {
@@ -81,7 +76,6 @@ func queryIntDefault(r *http.Request, key string, def int) int {
return v
}
func consoleTunnel(c *gin.Context) {
token := c.Query("token")
sessionID, err := services.VerifySessionToken(token)
@@ -89,36 +83,32 @@ func consoleTunnel(c *gin.Context) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
orgID := auth.OrgID(c)
sess, err := services.GetConsoleSession(orgID, sessionID)
instanceID := auth.InstanceID(c)
sess, err := services.GetConsoleSession(instanceID, sessionID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
if actor := actorFromCtx(c); actor != sess.User {
c.JSON(http.StatusForbidden, gin.H{"error": "session belongs to another user"})
return
}
if err := services.ConsumeSessionToken(orgID, sessionID); err != nil {
if err := services.ConsumeSessionToken(instanceID, sessionID); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token already used"})
return
}
srv, err := services.GetServer(auth.OrgID(c), sess.ServerID)
srv, err := services.GetServer(auth.InstanceID(c), sess.ServerID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
var privKey, passphrase string
if sess.Protocol == "ssh" && sess.KeyID != "" {
privKey, err = services.GetPrivateKey(auth.OrgID(c), sess.KeyID)
privKey, err = services.GetPrivateKey(auth.InstanceID(c), sess.KeyID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "selected key has no private material"})
return
@@ -128,7 +118,7 @@ func consoleTunnel(c *gin.Context) {
var rdpUser, rdpPass string
if sess.Protocol == "rdp" || sess.Protocol == "vnc" {
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(orgID, sessionID)
rdpUser, rdpPass, err = services.ConsumeConsoleRDPCreds(instanceID, sessionID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "could not load credentials"})
return
@@ -145,7 +135,6 @@ func consoleTunnel(c *gin.Context) {
guacdAddr = "guacd:4822"
}
connect := func(r *http.Request) (guac.Tunnel, error) {
config := guac.NewGuacamoleConfiguration()
config.Protocol = gp.Protocol
@@ -173,7 +162,7 @@ func consoleTunnel(c *gin.Context) {
wsServer := guac.NewWebsocketServer(connect)
wsServer.OnDisconnect = func(id string, r *http.Request, t guac.Tunnel) {
_ = services.EndConsoleSession(orgID, sessionID)
_ = services.EndConsoleSession(instanceID, sessionID)
}
wsServer.ServeHTTP(c.Writer, c.Request)
}
+40 -43
View File
@@ -27,7 +27,6 @@ func RegisterRoutes(r *gin.Engine) {
r.GET("/api/secrets/:group/values", secretsReadAuth(), esoGetGroup)
r.GET("/auth/bootstrap-status", auth.HandleBootstrapStatus)
r.POST("/auth/bootstrap", auth.HandleBootstrap)
r.POST("/auth/login", auth.HandleLocalLogin)
@@ -36,7 +35,6 @@ func RegisterRoutes(r *gin.Engine) {
r.GET("/auth/oidc/start", auth.HandleOIDCStart)
r.GET("/auth/oidc/callback", auth.HandleOIDCCallback)
apiGroup := r.Group("/api")
apiGroup.Use(auth.Middleware())
{
@@ -85,21 +83,21 @@ func RegisterRoutes(r *gin.Engine) {
registerMonitorRoutes(apiGroup)
registerChannelRoutes(apiGroup)
org := apiGroup.Group("/org")
org.Use(auth.RequireRole("owner", "admin"))
instance := apiGroup.Group("/instance")
instance.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)
instance.GET("/users", listInstanceUsers)
instance.POST("/users", createInstanceUser)
instance.PUT("/users/:id/role", updateInstanceUserRole)
instance.DELETE("/users/:id", deleteInstanceUser)
instance.GET("/oidc", getInstanceOIDC)
instance.PUT("/oidc", putInstanceOIDC)
}
}
}
func listServers(c *gin.Context) {
servers, err := services.ListServers(auth.OrgID(c))
servers, err := services.ListServers(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -108,7 +106,7 @@ func listServers(c *gin.Context) {
}
func createServer(c *gin.Context) {
s, token, err := services.CreateServer(auth.OrgID(c))
s, token, err := services.CreateServer(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -121,12 +119,12 @@ func createServer(c *gin.Context) {
}
func newServer(c *gin.Context) {
s, token, err := services.CreateServer(auth.OrgID(c))
s, token, err := services.CreateServer(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
services.LogEvent(auth.InstanceID(c), "server.created", actorFromCtx(c), s.ServerID, "", "pre-registration token issued")
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
@@ -155,15 +153,14 @@ func newServer(c *gin.Context) {
func getServer(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(auth.OrgID(c), id)
s, err := services.GetServer(auth.InstanceID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
assignments, _ := services.GetAssignmentsWithKeysForServer(auth.OrgID(c), id)
assignments, _ := services.GetAssignmentsWithKeysForServer(auth.InstanceID(c), id)
type serverResponse struct {
*models.Server
Keys interface{} `json:"keys"`
@@ -176,8 +173,8 @@ func getServer(c *gin.Context) {
func deleteServer(c *gin.Context) {
id := c.Param("id")
s, _ := services.GetServer(auth.OrgID(c), id)
if err := services.DeleteServer(auth.OrgID(c), id); err != nil {
s, _ := services.GetServer(auth.InstanceID(c), id)
if err := services.DeleteServer(auth.InstanceID(c), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -185,7 +182,7 @@ func deleteServer(c *gin.Context) {
if s != nil {
hostname = s.Hostname
}
services.LogEvent(auth.OrgID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
services.LogEvent(auth.InstanceID(c), "server.deleted", actorFromCtx(c), id, "", fmt.Sprintf("server %s deleted", hostname))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -204,7 +201,7 @@ func generateKey(c *gin.Context) {
body.Label = "generated"
}
s, err := services.GetServer(auth.OrgID(c), id)
s, err := services.GetServer(auth.InstanceID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -222,7 +219,7 @@ func generateKey(c *gin.Context) {
return
}
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))
services.LogEvent(auth.InstanceID(c), "key.generation_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("key generation dispatched (label=%s type=%s)", body.Label, body.KeyType))
c.JSON(http.StatusAccepted, gin.H{
"message": "key generation command sent to agent",
"command_id": cmdID,
@@ -231,7 +228,7 @@ func generateKey(c *gin.Context) {
}
func listKeys(c *gin.Context) {
keys, err := services.ListKeys(auth.OrgID(c))
keys, err := services.ListKeys(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -251,18 +248,18 @@ func createKey(c *gin.Context) {
return
}
key, err := services.CreateKey(auth.OrgID(c), body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase)
key, err := services.CreateKey(auth.InstanceID(c), body.Label, body.PublicKey, "uploaded", "", body.PrivateKey, body.Passphrase)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label))
services.LogEvent(auth.InstanceID(c), "key.uploaded", actorFromCtx(c), "", key.KeyID, fmt.Sprintf("key '%s' uploaded", key.Label))
c.JSON(http.StatusCreated, key)
}
func getPrivateKey(c *gin.Context) {
id := c.Param("id")
plaintext, err := services.GetPrivateKey(auth.OrgID(c), id)
plaintext, err := services.GetPrivateKey(auth.InstanceID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -272,13 +269,13 @@ func getPrivateKey(c *gin.Context) {
func getKey(c *gin.Context) {
id := c.Param("id")
key, err := services.GetKey(auth.OrgID(c), id)
key, err := services.GetKey(auth.InstanceID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
return
}
assignments, _ := services.GetAssignmentsWithServers(auth.OrgID(c), id)
assignments, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
type keyResponse struct {
*models.Key
@@ -292,8 +289,8 @@ func getKey(c *gin.Context) {
func deleteKey(c *gin.Context) {
id := c.Param("id")
k, _ := services.GetKey(auth.OrgID(c), id)
if err := services.DeleteKey(auth.OrgID(c), id); err != nil {
k, _ := services.GetKey(auth.InstanceID(c), id)
if err := services.DeleteKey(auth.InstanceID(c), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -301,7 +298,7 @@ func deleteKey(c *gin.Context) {
if k != nil {
label = k.Label
}
services.LogEvent(auth.OrgID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
services.LogEvent(auth.InstanceID(c), "key.deleted", actorFromCtx(c), "", id, fmt.Sprintf("key '%s' deleted", label))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
@@ -315,12 +312,12 @@ func assignKey(c *gin.Context) {
return
}
a, err := services.AssignKey(auth.OrgID(c), keyID, body.ServerID)
a, err := services.AssignKey(auth.InstanceID(c), keyID, body.ServerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
services.LogEvent(auth.InstanceID(c), "key.assigned", actorFromCtx(c), body.ServerID, keyID, fmt.Sprintf("key %s assigned to server %s", keyID, body.ServerID))
c.JSON(http.StatusCreated, a)
}
@@ -328,11 +325,11 @@ func revokeAssignment(c *gin.Context) {
keyID := c.Param("id")
serverID := c.Param("serverId")
if err := services.RevokeAssignment(auth.OrgID(c), keyID, serverID); err != nil {
if err := services.RevokeAssignment(auth.InstanceID(c), keyID, serverID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
services.LogEvent(auth.InstanceID(c), "key.revoked", actorFromCtx(c), serverID, keyID, fmt.Sprintf("key %s revoked from server %s", keyID, serverID))
c.JSON(http.StatusOK, gin.H{"revoked": true})
}
@@ -347,7 +344,7 @@ func getLatestAgentVersion(c *gin.Context) {
func updateAgent(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(auth.OrgID(c), id)
s, err := services.GetServer(auth.InstanceID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -358,7 +355,7 @@ func updateAgent(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
services.LogEvent(auth.InstanceID(c), "agent.update_dispatched", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("agent update dispatched to %s (version %s)", s.Hostname, version))
c.JSON(http.StatusAccepted, gin.H{
"message": "update command sent to agent",
"version": version,
@@ -367,7 +364,7 @@ func updateAgent(c *gin.Context) {
func applyUpdates(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(auth.OrgID(c), id)
s, err := services.GetServer(auth.InstanceID(c), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -377,7 +374,7 @@ func applyUpdates(c *gin.Context) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
services.LogEvent(auth.InstanceID(c), "updates.applied", actorFromCtx(c), s.ServerID, "", fmt.Sprintf("package update command dispatched to %s", s.Hostname))
c.JSON(http.StatusAccepted, gin.H{"message": "apply updates command sent to agent"})
}
@@ -444,7 +441,7 @@ func listAuditEvents(c *gin.Context) {
limit = n
}
}
events, err := services.ListAuditEvents(auth.OrgID(c), limit)
events, err := services.ListAuditEvents(auth.InstanceID(c), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -453,7 +450,7 @@ func listAuditEvents(c *gin.Context) {
}
func getSettings(c *gin.Context) {
s, err := services.GetSettings(auth.OrgID(c))
s, err := services.GetSettings(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -471,11 +468,11 @@ func saveSettings(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.SaveSettings(auth.OrgID(c), body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil {
if err := services.SaveSettings(auth.InstanceID(c), body.Alerts, body.Email, body.WorkflowLogRetentionDays); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated")
services.LogEvent(auth.InstanceID(c), "settings.updated", actorFromCtx(c), "", "", "alert settings updated")
c.JSON(http.StatusOK, gin.H{"saved": true})
}
+1 -4
View File
@@ -16,7 +16,7 @@ func handleInstallScriptWindows(c *gin.Context) {
if giteaHost == "" {
giteaHost = "gitea.example.com"
}
grpcHost := os.Getenv("GRPC_HOST")
script := fmt.Sprintf(
@@ -50,9 +50,6 @@ func handleInstallScriptWindows(c *gin.Context) {
c.String(http.StatusOK, script)
}
func handleUpdateScriptWindows(c *gin.Context) {
giteaHost := os.Getenv("GITEA_HOST")
if giteaHost == "" {
+9 -9
View File
@@ -22,7 +22,7 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
}
func listMonitors(c *gin.Context) {
monitors, err := services.ListMonitors(auth.OrgID(c))
monitors, err := services.ListMonitors(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -40,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(auth.OrgID(c), &m)
created, err := services.CreateMonitor(auth.InstanceID(c), &m)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -49,7 +49,7 @@ func createMonitor(c *gin.Context) {
}
func getMonitor(c *gin.Context) {
m, err := services.GetMonitor(auth.OrgID(c), c.Param("id"))
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -105,7 +105,7 @@ func updateMonitor(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := services.UpdateMonitor(auth.OrgID(c), c.Param("id"), upd); err != nil {
if err := services.UpdateMonitor(auth.InstanceID(c), c.Param("id"), upd); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -113,7 +113,7 @@ func updateMonitor(c *gin.Context) {
}
func deleteMonitor(c *gin.Context) {
if err := services.DeleteMonitor(auth.OrgID(c), c.Param("id")); err != nil {
if err := services.DeleteMonitor(auth.InstanceID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -121,7 +121,7 @@ func deleteMonitor(c *gin.Context) {
}
func getMonitorIncidents(c *gin.Context) {
m, err := services.GetMonitor(auth.OrgID(c), c.Param("id"))
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -130,7 +130,7 @@ func getMonitorIncidents(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
return
}
incidents, err := services.ListIncidents(auth.OrgID(c), c.Param("id"), 50)
incidents, err := services.ListIncidents(auth.InstanceID(c), c.Param("id"), 50)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -139,7 +139,7 @@ func getMonitorIncidents(c *gin.Context) {
}
func getMonitorUptime(c *gin.Context) {
m, err := services.GetMonitor(auth.OrgID(c), c.Param("id"))
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -149,7 +149,7 @@ func getMonitorUptime(c *gin.Context) {
return
}
since := time.Now().Add(-30 * 24 * time.Hour)
rollups, err := services.UptimeRollups(auth.OrgID(c), c.Param("id"), since)
rollups, err := services.UptimeRollups(auth.InstanceID(c), c.Param("id"), since)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+19 -23
View File
@@ -10,8 +10,8 @@ import (
"github.com/mrhid6/vantage/server/internal/services"
)
func listOrgUsers(c *gin.Context) {
users, err := services.ListUsers(auth.OrgID(c))
func listInstanceUsers(c *gin.Context) {
users, err := services.ListUsers(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -19,14 +19,11 @@ func listOrgUsers(c *gin.Context) {
c.JSON(http.StatusOK, users)
}
func actorMayGrantOwner(c *gin.Context) bool {
return auth.Role(c) == models.RoleOwner
}
func createOrgUser(c *gin.Context) {
func createInstanceUser(c *gin.Context) {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
@@ -47,7 +44,7 @@ func createOrgUser(c *gin.Context) {
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")
u, err := services.CreateUser(auth.InstanceID(c), body.Email, body.Password, body.Role, "local")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -55,7 +52,7 @@ func createOrgUser(c *gin.Context) {
c.JSON(http.StatusCreated, u)
}
func updateOrgUserRole(c *gin.Context) {
func updateInstanceUserRole(c *gin.Context) {
var body struct {
Role string `json:"role"`
}
@@ -68,12 +65,12 @@ func updateOrgUserRole(c *gin.Context) {
return
}
orgID, targetID := auth.OrgID(c), c.Param("id")
instanceID, targetID := auth.InstanceID(c), c.Param("id")
if targetID == auth.UserID(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot change your own role"})
return
}
target, err := services.GetUserInOrg(orgID, targetID)
target, err := services.GetUserInInstance(instanceID, targetID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
@@ -83,20 +80,20 @@ func updateOrgUserRole(c *gin.Context) {
return
}
if err := services.UpdateUserRole(orgID, targetID, body.Role); err != nil {
if err := services.UpdateUserRole(instanceID, targetID, body.Role); err != nil {
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func deleteOrgUser(c *gin.Context) {
orgID, targetID := auth.OrgID(c), c.Param("id")
func deleteInstanceUser(c *gin.Context) {
instanceID, targetID := auth.InstanceID(c), c.Param("id")
if targetID == auth.UserID(c) {
c.JSON(http.StatusForbidden, gin.H{"error": "you cannot remove your own account"})
return
}
target, err := services.GetUserInOrg(orgID, targetID)
target, err := services.GetUserInInstance(instanceID, targetID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
@@ -106,7 +103,7 @@ func deleteOrgUser(c *gin.Context) {
return
}
if err := services.DeleteUser(orgID, targetID); err != nil {
if err := services.DeleteUser(instanceID, targetID); err != nil {
c.JSON(orgUserErrStatus(err), gin.H{"error": err.Error()})
return
}
@@ -120,16 +117,15 @@ func orgUserErrStatus(err error) int {
return http.StatusInternalServerError
}
func getOrgOIDC(c *gin.Context) {
cfg, err := services.GetOrgOIDC(auth.OrgID(c))
func getInstanceOIDC(c *gin.Context) {
cfg, err := services.GetInstanceOIDC(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusOK, gin.H{"enabled": false, "client_secret_set": false})
return
}
c.JSON(http.StatusOK, gin.H{
"org_id": cfg.OrgID,
"instance_id": cfg.InstanceID,
"issuer": cfg.Issuer,
"client_id": cfg.ClientID,
"enabled": cfg.Enabled,
@@ -138,7 +134,7 @@ func getOrgOIDC(c *gin.Context) {
})
}
func putOrgOIDC(c *gin.Context) {
func putInstanceOIDC(c *gin.Context) {
var body struct {
Issuer string `json:"issuer"`
ClientID string `json:"client_id"`
@@ -149,11 +145,11 @@ func putOrgOIDC(c *gin.Context) {
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 {
if err := services.SaveOrgOIDC(auth.InstanceID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
auth.EvictOIDCProvider(auth.OrgID(c))
auth.EvictOIDCProvider(auth.InstanceID(c))
c.JSON(http.StatusOK, gin.H{"saved": true})
}
+22 -37
View File
@@ -11,22 +11,13 @@ import (
"github.com/mrhid6/vantage/server/internal/services"
)
var groupNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
func validName(s string) bool {
return s != "" && len(s) <= 128 && groupNamePattern.MatchString(s)
}
const ctxSecretsOrgKey = "km_secrets_org"
const ctxSecretsInstanceKey = "km_secrets_instance"
func secretsReadAuth() gin.HandlerFunc {
return func(c *gin.Context) {
@@ -36,29 +27,26 @@ func secretsReadAuth() gin.HandlerFunc {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing bearer token"})
return
}
orgID, ok := services.ResolveSecretsReadToken(authHeader[len(prefix):])
instanceID, ok := services.ResolveSecretsReadToken(authHeader[len(prefix):])
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set(ctxSecretsOrgKey, orgID)
c.Set(ctxSecretsInstanceKey, instanceID)
c.Next()
}
}
func esoGetGroup(c *gin.Context) {
group := c.Param("group")
orgID := c.GetString(ctxSecretsOrgKey)
if orgID == "" {
instanceID := c.GetString(ctxSecretsInstanceKey)
if instanceID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
values, err := services.GetSecretGroupDecrypted(orgID, group)
values, err := services.GetSecretGroupDecrypted(instanceID, group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "store error"})
return
@@ -71,7 +59,7 @@ func esoGetGroup(c *gin.Context) {
}
func listSecretGroups(c *gin.Context) {
groups, err := services.ListSecretGroups(auth.OrgID(c))
groups, err := services.ListSecretGroups(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -79,8 +67,6 @@ func listSecretGroups(c *gin.Context) {
c.JSON(http.StatusOK, groups)
}
func createSecretGroup(c *gin.Context) {
var body struct {
Group string `json:"group" binding:"required"`
@@ -104,17 +90,17 @@ func createSecretGroup(c *gin.Context) {
return
}
}
if err := services.UpsertSecrets(auth.OrgID(c), body.Group, body.Values); err != nil {
if err := services.UpsertSecrets(auth.InstanceID(c), body.Group, body.Values); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
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), ", ")))
services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' created with keys: %s", body.Group, strings.Join(services.SortedKeys(body.Values), ", ")))
c.JSON(http.StatusCreated, gin.H{"group": body.Group})
}
func getSecretGroup(c *gin.Context) {
group := c.Param("group")
secrets, err := services.GetSecretGroup(auth.OrgID(c), group)
secrets, err := services.GetSecretGroup(auth.InstanceID(c), group)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -126,7 +112,6 @@ func getSecretGroup(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"group": group, "secrets": secrets})
}
func putSecretGroup(c *gin.Context) {
group := c.Param("group")
if !validName(group) {
@@ -148,11 +133,11 @@ func putSecretGroup(c *gin.Context) {
return
}
}
if err := services.UpsertSecrets(auth.OrgID(c), group, values); err != nil {
if err := services.UpsertSecrets(auth.InstanceID(c), group, values); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
services.LogEvent(auth.InstanceID(c), "secret.updated", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' keys updated: %s", group, strings.Join(services.SortedKeys(values), ", ")))
c.JSON(http.StatusOK, gin.H{"saved": true})
}
@@ -165,42 +150,42 @@ func revealSecret(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
value, err := services.RevealSecret(auth.OrgID(c), group, body.Key)
value, err := services.RevealSecret(auth.InstanceID(c), group, body.Key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
services.LogEvent(auth.InstanceID(c), "secret.revealed", actorFromCtx(c), "", "", fmt.Sprintf("value of '%s/%s' revealed", group, body.Key))
c.JSON(http.StatusOK, gin.H{"value": value})
}
func deleteSecretKey(c *gin.Context) {
group := c.Param("group")
key := c.Param("key")
if err := services.DeleteSecret(auth.OrgID(c), group, key); err != nil {
if err := services.DeleteSecret(auth.InstanceID(c), group, key); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
services.LogEvent(auth.InstanceID(c), "secret.deleted", actorFromCtx(c), "", "", fmt.Sprintf("key '%s' deleted from group '%s'", key, group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func deleteSecretGroup(c *gin.Context) {
group := c.Param("group")
if err := services.DeleteSecretGroup(auth.OrgID(c), group); err != nil {
if err := services.DeleteSecretGroup(auth.InstanceID(c), group); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
services.LogEvent(auth.InstanceID(c), "secretgroup.deleted", actorFromCtx(c), "", "", fmt.Sprintf("group '%s' deleted", group))
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func rotateSecretsToken(c *gin.Context) {
token, err := services.RotateSecretsReadToken(auth.OrgID(c))
token, err := services.RotateSecretsReadToken(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
services.LogEvent(auth.InstanceID(c), "secrets.token_rotated", actorFromCtx(c), "", "", "ESO read token rotated")
c.JSON(http.StatusOK, gin.H{"token": token})
}
+36 -39
View File
@@ -81,7 +81,7 @@ func streamServerRunLog(c *gin.Context) {
sendNew := func() {
f, err := os.Open(path)
if err != nil {
return
return
}
defer f.Close()
if _, err := f.Seek(offset, 0); err != nil {
@@ -94,7 +94,7 @@ func streamServerRunLog(c *gin.Context) {
break
}
offset += int64(n)
for _, line := range splitSSE(buf[:n]) {
_, _ = c.Writer.WriteString("data: " + line + "\n")
}
@@ -106,11 +106,11 @@ func streamServerRunLog(c *gin.Context) {
ctx := c.Request.Context()
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
orgID := auth.OrgID(c)
instanceID := auth.InstanceID(c)
for {
sendNew()
if serverRunTerminal(orgID, runID, serverID) {
sendNew()
if serverRunTerminal(instanceID, runID, serverID) {
sendNew()
_, _ = c.Writer.WriteString("event: done\ndata: end\n\n")
flusher.Flush()
return
@@ -123,9 +123,8 @@ func streamServerRunLog(c *gin.Context) {
}
}
func serverRunTerminal(orgID, runID, serverID string) bool {
r, err := services.GetRun(orgID, runID)
func serverRunTerminal(instanceID, runID, serverID string) bool {
r, err := services.GetRun(instanceID, runID)
if err != nil {
return true
}
@@ -141,15 +140,13 @@ func serverRunTerminal(orgID, runID, serverID string) bool {
return true
}
func splitSSE(b []byte) []string {
s := strings.ReplaceAll(string(b), "\r", "")
return strings.Split(s, "\n")
}
func listSteps(c *gin.Context) {
steps, err := services.ListSteps(auth.OrgID(c))
steps, err := services.ListSteps(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -158,7 +155,7 @@ func listSteps(c *gin.Context) {
}
func stepUsage(c *gin.Context) {
counts, err := services.StepUsageCounts(auth.OrgID(c))
counts, err := services.StepUsageCounts(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -172,12 +169,12 @@ func createStep(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := services.CreateStep(auth.OrgID(c), s)
out, err := services.CreateStep(auth.InstanceID(c), s)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name))
services.LogEvent(auth.InstanceID(c), "workflow.step_created", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' created", out.Name))
c.JSON(http.StatusCreated, out)
}
@@ -187,25 +184,25 @@ func updateStep(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.UpdateStep(auth.OrgID(c), c.Param("id"), s); err != nil {
if err := services.UpdateStep(auth.InstanceID(c), c.Param("id"), s); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated")
services.LogEvent(auth.InstanceID(c), "workflow.step_updated", actorFromCtx(c), "", c.Param("id"), "step updated")
c.JSON(http.StatusOK, gin.H{"updated": true})
}
func deleteStep(c *gin.Context) {
if err := services.DeleteStep(auth.OrgID(c), c.Param("id")); err != nil {
if err := services.DeleteStep(auth.InstanceID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted")
services.LogEvent(auth.InstanceID(c), "workflow.step_deleted", actorFromCtx(c), "", c.Param("id"), "step deleted")
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func exportStep(c *gin.Context) {
b, err := services.ExportStep(auth.OrgID(c), c.Param("id"))
b, err := services.ExportStep(auth.InstanceID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -215,16 +212,16 @@ func exportStep(c *gin.Context) {
}
func seedDefaults(c *gin.Context) {
created, updated, err := services.SeedDefaultSteps(auth.OrgID(c))
created, updated, err := services.SeedDefaultSteps(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated))
services.LogEvent(auth.InstanceID(c), "workflow.defaults_synced", actorFromCtx(c), "", "", fmt.Sprintf("default steps synced: %d created, %d updated", created, updated))
c.JSON(http.StatusOK, gin.H{"created": created, "updated": updated})
}
const maxStepBodyBytes = 1 << 20
const maxStepBodyBytes = 1 << 20
func importStep(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxStepBodyBytes)
@@ -233,12 +230,12 @@ func importStep(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := services.ImportStepToLibrary(auth.OrgID(c), body)
out, err := services.ImportStepToLibrary(auth.InstanceID(c), body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name))
services.LogEvent(auth.InstanceID(c), "workflow.step_imported", actorFromCtx(c), "", out.StepID, fmt.Sprintf("step '%s' imported", out.Name))
c.JSON(http.StatusCreated, out)
}
@@ -258,7 +255,7 @@ func parseStep(c *gin.Context) {
}
func listWorkflows(c *gin.Context) {
wfs, err := services.ListWorkflows(auth.OrgID(c))
wfs, err := services.ListWorkflows(auth.InstanceID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -272,17 +269,17 @@ func createWorkflow(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := services.CreateWorkflow(auth.OrgID(c), w)
out, err := services.CreateWorkflow(auth.InstanceID(c), w)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name))
services.LogEvent(auth.InstanceID(c), "workflow.created", actorFromCtx(c), "", out.WorkflowID, fmt.Sprintf("workflow '%s' created", out.Name))
c.JSON(http.StatusCreated, out)
}
func getWorkflow(c *gin.Context) {
w, err := services.GetWorkflow(auth.OrgID(c), c.Param("id"))
w, err := services.GetWorkflow(auth.InstanceID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -296,12 +293,12 @@ func updateWorkflow(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.UpdateWorkflow(auth.OrgID(c), c.Param("id"), w); err != nil {
if err := services.UpdateWorkflow(auth.InstanceID(c), c.Param("id"), w); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
updated, err := services.GetWorkflow(auth.OrgID(c), c.Param("id"))
services.LogEvent(auth.InstanceID(c), "workflow.updated", actorFromCtx(c), "", c.Param("id"), "workflow updated")
updated, err := services.GetWorkflow(auth.InstanceID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -310,21 +307,21 @@ func updateWorkflow(c *gin.Context) {
}
func deleteWorkflow(c *gin.Context) {
if err := services.DeleteWorkflow(auth.OrgID(c), c.Param("id")); err != nil {
if err := services.DeleteWorkflow(auth.InstanceID(c), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted")
services.LogEvent(auth.InstanceID(c), "workflow.deleted", actorFromCtx(c), "", c.Param("id"), "workflow deleted")
c.JSON(http.StatusOK, gin.H{"deleted": true})
}
func runWorkflow(c *gin.Context) {
runID, err := services.TriggerWorkflow(auth.OrgID(c), c.Param("id"), actorFromCtx(c))
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID))
services.LogEvent(auth.InstanceID(c), "workflow.run_triggered", actorFromCtx(c), "", c.Param("id"), fmt.Sprintf("run %s triggered", runID))
c.JSON(http.StatusAccepted, gin.H{"run_id": runID})
}
@@ -335,7 +332,7 @@ func listWorkflowRuns(c *gin.Context) {
limit = n
}
}
runs, err := services.ListRuns(auth.OrgID(c), c.Param("id"), limit)
runs, err := services.ListRuns(auth.InstanceID(c), c.Param("id"), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -344,7 +341,7 @@ func listWorkflowRuns(c *gin.Context) {
}
func getRun(c *gin.Context) {
r, err := services.GetRun(auth.OrgID(c), c.Param("runId"))
r, err := services.GetRun(auth.InstanceID(c), c.Param("runId"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
@@ -353,10 +350,10 @@ func getRun(c *gin.Context) {
}
func cancelRun(c *gin.Context) {
if err := services.CancelRun(auth.OrgID(c), c.Param("runId")); err != nil {
if err := services.CancelRun(auth.InstanceID(c), c.Param("runId")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
services.LogEvent(auth.OrgID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
services.LogEvent(auth.InstanceID(c), "workflow.run_cancelled", actorFromCtx(c), "", c.Param("runId"), "run cancelled")
c.JSON(http.StatusOK, gin.H{"cancelled": true})
}
@@ -12,7 +12,7 @@ import (
)
type cachedOrg struct {
org *models.Org
org *models.Instance
at time.Time
}
@@ -23,9 +23,6 @@ var (
const orgCacheTTL = 60 * time.Second
func appRootLabel() string {
if v := os.Getenv("APP_ROOT_LABEL"); v != "" {
return strings.ToLower(v)
@@ -33,15 +30,13 @@ func appRootLabel() string {
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 ""
@@ -55,7 +50,7 @@ func hostSlug(host string) string {
return parts[0]
}
func OrgFromHost(c *gin.Context) (*models.Org, bool) {
func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
slug := hostSlug(c.Request.Host)
if slug == "" {
return nil, false
@@ -67,10 +62,9 @@ func OrgFromHost(c *gin.Context) (*models.Org, bool) {
}
orgCacheMu.Unlock()
org, err := services.GetOrgBySlug(slug)
org, err := services.GetInstanceBySlug(slug)
if err != nil || org == nil {
return nil, false
}
orgCacheMu.Lock()
+27 -32
View File
@@ -37,7 +37,7 @@ func HandleLocalLogin(c *gin.Context) {
return
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email,
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
@@ -50,13 +50,13 @@ func HandleLocalLogin(c *gin.Context) {
func HandleBootstrapStatus(c *gin.Context) {
var (
n int64
err error
orgName string
n int64
err error
instName string
)
if org, ok := OrgFromHost(c); ok {
n, err = services.CountOrgUsers(org.OrgID)
orgName = org.Name
if inst, ok := InstanceFromHost(c); ok {
n, err = services.CountInstanceUsers(inst.InstanceID)
instName = inst.Name
} else {
n, err = services.CountUsers()
}
@@ -64,12 +64,9 @@ func HandleBootstrapStatus(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0, "org_name": orgName})
c.JSON(http.StatusOK, gin.H{"needs_setup": n == 0, "instance_name": instName})
}
func HandleBootstrap(c *gin.Context) {
n, err := services.CountUsers()
if err != nil {
@@ -81,34 +78,34 @@ func HandleBootstrap(c *gin.Context) {
return
}
var body struct {
OrgName string `json:"org_name"`
Email string `json:"email"`
Password string `json:"password"`
InstanceName string `json:"instance_name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.OrgName == "" || body.Email == "" || len(body.Password) < 8 {
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceName == "" || 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()
orgCount, err := services.CountInstances()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var org *models.Org
var inst *models.Instance
switch orgCount {
case 0:
org, err = services.CreateOrg(body.OrgName)
inst, err = services.CreateInstance(body.InstanceName)
case 1:
var existing *models.Org
existing, err = services.FirstOrg()
var existing *models.Instance
existing, err = services.FirstInstance()
if err == nil {
org, err = services.AdoptOrg(existing.OrgID, body.OrgName)
inst, err = services.AdoptInstance(existing.InstanceID, body.InstanceName)
}
default:
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf(
"cannot bootstrap: %d organizations already exist but no users do; "+
"create the owner against the intended org rather than through setup, "+
"create the owner against the intended inst rather than through setup, "+
"or remove the unintended orgs and retry", orgCount)})
return
}
@@ -116,20 +113,20 @@ func HandleBootstrap(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, err := services.CreateUser(org.OrgID, body.Email, body.Password, "owner", "local")
u, err := services.CreateUser(inst.InstanceID, body.Email, body.Password, "owner", "local")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sessionID, err := SaveSession(c.Request.Context(), &Session{
UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email,
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
return
}
SetSessionCookie(c, sessionID)
c.JSON(http.StatusCreated, gin.H{"org": org, "slug": org.Slug})
c.JSON(http.StatusCreated, gin.H{"instance": inst, "slug": inst.Slug})
}
func HandleMe(c *gin.Context) {
@@ -143,14 +140,12 @@ func HandleMe(c *gin.Context) {
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"})
if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID {
c.JSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"})
return
}
org, _ := services.GetOrg(sess.OrgID)
c.JSON(http.StatusOK, gin.H{"user": sess, "org": org})
inst, _ := services.GetInstance(sess.InstanceID)
c.JSON(http.StatusOK, gin.H{"user": sess, "instance": inst})
}
+5 -5
View File
@@ -14,9 +14,9 @@ func GetSessionFromContext(c *gin.Context) *Session {
return sess
}
func OrgID(c *gin.Context) string {
func InstanceID(c *gin.Context) string {
if s := GetSessionFromContext(c); s != nil {
return s.OrgID
return s.InstanceID
}
return ""
}
@@ -62,15 +62,15 @@ func Middleware() gin.HandlerFunc {
return
}
if sess.OrgID == "" {
if sess.InstanceID == "" {
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"})
if hostInstance, ok := InstanceFromHost(c); ok && hostInstance.InstanceID != sess.InstanceID {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "instance host mismatch"})
return
}
+17 -17
View File
@@ -18,9 +18,9 @@ var (
provCache = map[string]*oidc.Provider{}
)
func EvictOIDCProvider(orgID string) {
func EvictOIDCProvider(instanceID string) {
provMu.Lock()
delete(provCache, orgID)
delete(provCache, instanceID)
provMu.Unlock()
}
@@ -32,17 +32,17 @@ func redirectURL(c *gin.Context) string {
return fmt.Sprintf("%s://%s/auth/oidc/callback", scheme, c.Request.Host)
}
func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Provider, *oauth2.Config, error) {
cfg, err := services.GetOrgOIDC(orgID)
func providerForOrg(ctx context.Context, c *gin.Context, instanceID string) (*oidc.Provider, *oauth2.Config, error) {
cfg, err := services.GetInstanceOIDC(instanceID)
if err != nil || !cfg.Enabled {
return nil, nil, fmt.Errorf("org SSO not configured")
return nil, nil, fmt.Errorf("inst SSO not configured")
}
secret, err := services.GetOrgOIDCSecret(orgID)
secret, err := services.GetInstanceOIDCSecret(instanceID)
if err != nil {
return nil, nil, err
}
provMu.Lock()
p := provCache[orgID]
p := provCache[instanceID]
provMu.Unlock()
if p == nil {
p, err = oidc.NewProvider(ctx, cfg.Issuer)
@@ -50,7 +50,7 @@ func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Pr
return nil, nil, err
}
provMu.Lock()
provCache[orgID] = p
provCache[instanceID] = p
provMu.Unlock()
}
return p, &oauth2.Config{
@@ -61,13 +61,13 @@ func providerForOrg(ctx context.Context, c *gin.Context, orgID string) (*oidc.Pr
}
func HandleOIDCStart(c *gin.Context) {
org, ok := OrgFromHost(c)
inst, ok := InstanceFromHost(c)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown organization host"})
return
}
ctx := c.Request.Context()
_, oauthCfg, err := providerForOrg(ctx, c, org.OrgID)
_, oauthCfg, err := providerForOrg(ctx, c, inst.InstanceID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -77,7 +77,7 @@ func HandleOIDCStart(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state gen failed"})
return
}
if err := SaveStateOrg(ctx, state, org.OrgID); err != nil {
if err := SaveStateOrg(ctx, state, inst.InstanceID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"})
return
}
@@ -86,12 +86,12 @@ func HandleOIDCStart(c *gin.Context) {
func HandleOIDCCallback(c *gin.Context) {
ctx := c.Request.Context()
orgID, ok := ConsumeStateOrg(ctx, c.Query("state"))
instanceID, ok := ConsumeStateOrg(ctx, c.Query("state"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
return
}
provider, oauthCfg, err := providerForOrg(ctx, c, orgID)
provider, oauthCfg, err := providerForOrg(ctx, c, instanceID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -123,19 +123,19 @@ func HandleOIDCCallback(c *gin.Context) {
email := strings.ToLower(claims.Email)
u, err := services.GetUserByEmail(email)
if err != nil {
u, err = services.CreateUser(orgID, email, "", "member", "oidc")
u, err = services.CreateUser(instanceID, email, "", "member", "oidc")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "provisioning failed"})
return
}
} else if u.OrgID != orgID {
} else if u.InstanceID != instanceID {
c.JSON(http.StatusForbidden, gin.H{"error": "email belongs to a different organization"})
return
}
sessionID, err := SaveSession(ctx, &Session{
UserID: u.UserID, OrgID: u.OrgID, Role: u.Role, Email: u.Email, Name: claims.Name,
UserID: u.UserID, InstanceID: u.InstanceID, Role: u.Role, Email: u.Email, Name: claims.Name,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"})
+10 -10
View File
@@ -16,11 +16,11 @@ const sessionPrefix = "km:session:"
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"`
UserID string `json:"user_id"`
InstanceID string `json:"instance_id"`
Role string `json:"role"`
Email string `json:"email"`
Name string `json:"name"`
}
var rdb *redis.Client
@@ -71,14 +71,14 @@ func DeleteSession(ctx context.Context, id string) error {
return rdb.Del(ctx, sessionPrefix+id).Err()
}
func SaveStateOrg(ctx context.Context, state, orgID string) error {
return rdb.Set(ctx, statePrefix+state, orgID, 10*time.Minute).Err()
func SaveStateOrg(ctx context.Context, state, instanceID string) error {
return rdb.Set(ctx, statePrefix+state, instanceID, 10*time.Minute).Err()
}
func ConsumeStateOrg(ctx context.Context, state string) (string, bool) {
orgID, err := rdb.GetDel(ctx, statePrefix+state).Result()
if err != nil || orgID == "" {
instanceID, err := rdb.GetDel(ctx, statePrefix+state).Result()
if err != nil || instanceID == "" {
return "", false
}
return orgID, true
return instanceID, true
}
+5 -12
View File
@@ -12,7 +12,6 @@ import (
"time"
)
const (
TypeHTTP = "http"
TypeTCP = "tcp"
@@ -20,7 +19,6 @@ const (
TypeTLS = "tls"
)
type Spec struct {
Type string
URL string
@@ -30,11 +28,10 @@ type Spec struct {
ExpectedStatus int
Keyword string
TLSWarnDays int
Insecure bool
Insecure bool
TimeoutSec int
}
type Result struct {
Up bool
LatencyMs int
@@ -50,7 +47,6 @@ func (s Spec) timeout() time.Duration {
return time.Duration(t) * time.Second
}
func Run(ctx context.Context, s Spec) Result {
switch s.Type {
case TypeHTTP:
@@ -77,7 +73,7 @@ func runHTTP(ctx context.Context, s Spec) Result {
}
client := &http.Client{Timeout: s.timeout()}
if s.Insecure {
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
}
start := time.Now()
req, err := http.NewRequestWithContext(ctx, method, s.URL, nil)
@@ -156,9 +152,6 @@ func runTLS(ctx context.Context, s Spec) Result {
func msSince(t time.Time) int { return int(time.Since(t).Milliseconds()) }
func runICMP(ctx context.Context, s Spec) Result {
dst, err := net.ResolveIPAddr("ip4", s.Host)
if err != nil {
@@ -188,18 +181,18 @@ func runICMP(ctx context.Context, s Spec) Result {
if err != nil {
return Result{LatencyMs: msSince(start), Message: "no reply"}
}
if n < 28 || peer.String() != dst.String() {
continue
}
if reply[20] == 0 {
if reply[20] == 0 {
return Result{Up: true, LatencyMs: msSince(start)}
}
}
}
func icmpEcho(id, seq int) []byte {
b := []byte{8, 0, 0, 0, byte(id >> 8), byte(id), byte(seq >> 8), byte(seq)}
cs := icmpChecksum(b)
b[2] = byte(cs >> 8)
+1 -2
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
)
type JSONCodec struct{}
func (JSONCodec) Marshal(v interface{}) ([]byte, error) {
@@ -16,5 +15,5 @@ func (JSONCodec) Unmarshal(data []byte, v interface{}) error {
}
func (JSONCodec) Name() string {
return "proto"
return "proto"
}
+12 -36
View File
@@ -1,6 +1,3 @@
package pb
import (
@@ -11,8 +8,6 @@ import (
"google.golang.org/grpc/status"
)
type RegisterRequest struct {
ServerId string `json:"server_id"`
PreRegToken string `json:"pre_reg_token"`
@@ -47,8 +42,6 @@ type UploadKeyResponse struct {
KeyId string `json:"key_id"`
}
type PackageUpdate struct {
Name string `json:"name"`
CurrentVersion string `json:"current_version,omitempty"`
@@ -63,8 +56,6 @@ type ReportUpdatesRequest struct {
type ReportUpdatesResponse struct{}
type CPUReport struct {
Model string `json:"model,omitempty"`
Cores int `json:"cores,omitempty"`
@@ -95,8 +86,6 @@ type InventoryReport struct {
}
type InventoryReportResponse struct{}
type MonitorSpec struct {
MonitorId string `json:"monitor_id"`
Type string `json:"type"`
@@ -119,11 +108,11 @@ type SyncMonitorsResponse struct {
Monitors []MonitorSpec `json:"monitors,omitempty"`
}
type CheckResult struct {
MonitorId string `json:"monitor_id"`
Up bool `json:"up"`
LatencyMs int `json:"latency_ms"`
Message string `json:"message,omitempty"`
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
MonitorId string `json:"monitor_id"`
Up bool `json:"up"`
LatencyMs int `json:"latency_ms"`
Message string `json:"message,omitempty"`
CertExpiryUnix int64 `json:"cert_expiry_unix,omitempty"`
}
type ReportChecksRequest struct {
ServerId string `json:"server_id"`
@@ -144,8 +133,6 @@ type ServerCommand struct {
CleanupWorkspace *CleanupWorkspaceCmd `json:"cleanup_workspace,omitempty"`
}
type CleanupWorkspaceCmd struct {
WorkspaceId string `json:"workspace_id"`
}
@@ -168,12 +155,12 @@ type GenerateKeyCmd struct {
}
type AgentMessage struct {
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Ready *AgentReady `json:"ready,omitempty"`
Result *CommandResult `json:"result,omitempty"`
StepResult *StepResult `json:"step_result,omitempty"`
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
ServerId string `json:"server_id"`
AgentToken string `json:"agent_token"`
Ready *AgentReady `json:"ready,omitempty"`
Result *CommandResult `json:"result,omitempty"`
StepResult *StepResult `json:"step_result,omitempty"`
StepOutput *StepOutputChunk `json:"step_output,omitempty"`
}
type AgentReady struct{}
@@ -189,8 +176,7 @@ type RunStepCmd struct {
Script string `json:"script"`
Env map[string]string `json:"env,omitempty"`
TimeoutSeconds int `json:"timeout_seconds,omitempty"`
WorkspaceId string `json:"workspace_id,omitempty"`
}
@@ -209,8 +195,6 @@ type StepOutputChunk struct {
Eof bool `json:"eof,omitempty"`
}
type Vantage_CommandStreamServer interface {
Send(*ServerCommand) error
Recv() (*AgentMessage, error)
@@ -233,8 +217,6 @@ func (s *keyManagerCommandStreamServer) Recv() (*AgentMessage, error) {
return m, nil
}
type Vantage_CommandStreamClient interface {
Send(*AgentMessage) error
Recv() (*ServerCommand, error)
@@ -257,8 +239,6 @@ func (c *vantageCommandStreamClient) Recv() (*ServerCommand, error) {
return m, nil
}
type VantageServer interface {
Register(context.Context, *RegisterRequest) (*RegisterResponse, error)
SyncKeys(context.Context, *SyncRequest) (*SyncResponse, error)
@@ -304,8 +284,6 @@ func (UnimplementedVantageServer) CommandStream(Vantage_CommandStreamServer) err
return status.Errorf(codes.Unimplemented, "method CommandStream not implemented")
}
type VantageClient interface {
Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error)
SyncKeys(ctx context.Context, in *SyncRequest, opts ...grpc.CallOption) (*SyncResponse, error)
@@ -389,8 +367,6 @@ func (c *keyManagerClient) CommandStream(ctx context.Context, opts ...grpc.CallO
return &vantageCommandStreamClient{stream}, nil
}
func RegisterVantageServer(s grpc.ServiceRegistrar, srv VantageServer) {
s.RegisterService(&Vantage_ServiceDesc, srv)
}
+7 -13
View File
@@ -62,14 +62,12 @@ func (s *vantageServer) UploadGeneratedKey(ctx context.Context, req *pb.UploadKe
return nil, status.Errorf(codes.Unauthenticated, "invalid agent token")
}
key, err := services.CreateKey(srv.OrgID, req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "")
key, err := services.CreateKey(srv.InstanceID, req.Label, req.PublicKey, "generated", srv.ServerID, req.PrivateKey, "")
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to store key: %v", err)
}
if _, err := services.AssignKey(srv.OrgID, key.KeyID, srv.ServerID); err != nil {
if _, err := services.AssignKey(srv.InstanceID, key.KeyID, srv.ServerID); err != nil {
log.Printf("failed to auto-assign generated key: %v", err)
}
@@ -112,7 +110,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.OrgID, srv.ServerID)
monitors, err := services.ListMonitorsForRunner(srv.InstanceID, srv.ServerID)
if err != nil {
return nil, status.Errorf(codes.Internal, "list monitors")
}
@@ -147,7 +145,7 @@ func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRe
t := time.Unix(r.CertExpiryUnix, 0)
res.CertExpiry = &t
}
if err := services.IngestResult(srv.OrgID, srv.ServerID, r.MonitorId, res); err != nil {
if err := services.IngestResult(srv.InstanceID, srv.ServerID, r.MonitorId, res); err != nil {
log.Printf("ingest check %s: %v", r.MonitorId, err)
}
}
@@ -155,7 +153,7 @@ func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRe
}
func (s *vantageServer) CommandStream(stream pb.Vantage_CommandStreamServer) error {
msg, err := stream.Recv()
if err != nil {
return status.Errorf(codes.InvalidArgument, "expected initial auth message: %v", err)
@@ -176,8 +174,6 @@ 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)
go func() {
for {
m, err := stream.Recv()
@@ -224,15 +220,13 @@ func StartGRPC(port int) error {
}
s := grpc.NewServer(
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 20 * time.Second,
PermitWithoutStream: false,
}),
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: 45 * time.Second,
Timeout: 10 * time.Second,
}),
+1 -1
View File
@@ -8,7 +8,7 @@ import (
type Assignment struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
OrgID string `bson:"org_id" json:"org_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
KeyID string `bson:"key_id" json:"key_id"`
ServerID string `bson:"server_id" json:"server_id"`
AssignedAt time.Time `bson:"assigned_at" json:"assigned_at"`
+8 -8
View File
@@ -7,12 +7,12 @@ 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"`
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
Details string `bson:"details" json:"details"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
ID bson.ObjectID `bson:"_id,omitempty" json:"id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
EventType string `bson:"event_type" json:"event_type"`
Actor string `bson:"actor" json:"actor"`
ServerID string `bson:"server_id,omitempty" json:"server_id,omitempty"`
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
Details string `bson:"details" json:"details"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+8 -12
View File
@@ -6,7 +6,6 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
)
const (
ChannelWebhook = "webhook"
ChannelSMTP = "smtp"
@@ -15,16 +14,13 @@ const (
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"`
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
ChannelID string `bson:"channel_id" json:"channel_id"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"`
Config map[string]string `bson:"config" json:"config"`
Enabled bool `bson:"enabled" json:"enabled"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+10 -12
View File
@@ -7,19 +7,17 @@ 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"`
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"`
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
SessionID string `bson:"session_id" json:"session_id"`
ServerID string `bson:"server_id" json:"server_id"`
Protocol string `bson:"protocol" json:"protocol"`
KeyID string `bson:"key_id,omitempty" json:"key_id,omitempty"`
User string `bson:"user" json:"user"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
EndedAt *time.Time `bson:"ended_at,omitempty" json:"ended_at,omitempty"`
ClientIP string `bson:"client_ip,omitempty" json:"client_ip,omitempty"`
TokenConsumedAt *time.Time `bson:"token_consumed_at,omitempty" json:"-"`
SSHUsername string `bson:"ssh_username,omitempty" json:"ssh_username,omitempty"`
+7
View File
@@ -0,0 +1,7 @@
package models
import shared "github.com/mrhid6/vantage/shared/models"
// Instance is defined in the shared module because sitesvc and the admin
// control plane write the same documents.
type Instance = shared.Instance
+2 -2
View File
@@ -8,12 +8,12 @@ import (
type Key struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
OrgID string `bson:"org_id" json:"org_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
KeyID string `bson:"key_id" json:"key_id"`
Label string `bson:"label" json:"label"`
PublicKey string `bson:"public_key" json:"public_key"`
Fingerprint string `bson:"fingerprint" json:"fingerprint"`
Source string `bson:"source" json:"source"`
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"`
+10 -14
View File
@@ -6,7 +6,6 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
)
const (
MonitorHTTP = "http"
MonitorTCP = "tcp"
@@ -14,15 +13,12 @@ const (
MonitorTLS = "tls"
)
const (
StatusUp = "up"
StatusDown = "down"
StatusPending = "pending"
)
const RunnerServer = "server"
type MonitorTarget struct {
@@ -33,29 +29,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"`
Insecure bool `bson:"insecure,omitempty" json:"insecure,omitempty"`
}
type MonitorState struct {
Status string `bson:"status" json:"status"`
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"`
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"`
InstanceID string `bson:"instance_id" json:"instance_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"`
Type string `bson:"type" json:"type"`
Target MonitorTarget `bson:"target" json:"target"`
IntervalSec int `bson:"interval_sec" json:"interval_sec"`
Runner string `bson:"runner" json:"runner"`
Retries int `bson:"retries" json:"retries"`
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"`
@@ -63,7 +59,7 @@ type Monitor struct {
}
type Incident struct {
OrgID string `bson:"org_id" json:"org_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
IncidentID string `bson:"incident_id" json:"incident_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
StartedAt time.Time `bson:"started_at" json:"started_at"`
@@ -72,9 +68,9 @@ type Incident struct {
}
type Rollup struct {
OrgID string `bson:"org_id" json:"org_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
PeriodStart time.Time `bson:"period_start" json:"period_start"`
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
@@ -1,15 +0,0 @@
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"`
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
type OrgOIDC struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
OrgID string `bson:"org_id" json:"org_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
Issuer string `bson:"issuer" json:"issuer"`
ClientID string `bson:"client_id" json:"client_id"`
ClientSecretEnc string `bson:"client_secret_enc,omitempty" json:"-"`
+1 -4
View File
@@ -6,18 +6,15 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
)
type Secret struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
OrgID string `bson:"org_id" json:"org_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
Group string `bson:"group" json:"group"`
Key string `bson:"key" json:"key"`
EncryptedValue string `bson:"encrypted_value" json:"-"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type GroupSummary struct {
Group string `json:"group"`
KeyCount int `json:"key_count"`
+1 -1
View File
@@ -45,7 +45,7 @@ type Inventory struct {
type Server struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
OrgID string `bson:"org_id" json:"org_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
ServerID string `bson:"server_id" json:"server_id"`
Hostname string `bson:"hostname" json:"hostname"`
IPAddress string `bson:"ip_address" json:"ip_address"`
+6 -38
View File
@@ -1,42 +1,10 @@
package models
import (
"time"
import shared "github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
type (
Settings = shared.Settings
AlertSettings = shared.AlertSettings
EmailSettings = shared.EmailSettings
SecretsSettings = shared.SecretsSettings
)
type AlertSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
WebhookURL string `bson:"webhook_url" json:"webhook_url"`
OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"`
}
type EmailSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
SMTPHost string `bson:"smtp_host" json:"smtp_host"`
SMTPPort int `bson:"smtp_port" json:"smtp_port"`
Username string `bson:"username" json:"username"`
Password string `bson:"password" json:"password"`
FromAddr string `bson:"from_addr" json:"from_addr"`
ToAddrs []string `bson:"to_addrs" json:"to_addrs"`
UseTLS bool `bson:"use_tls" json:"use_tls"`
}
type SecretsSettings struct {
ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"`
ReadTokenSet bool `bson:"-" json:"read_token_set"`
RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"`
}
type Settings struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
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 *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"`
}
+6 -28
View File
@@ -1,35 +1,13 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
import shared "github.com/mrhid6/vantage/shared/models"
type User = shared.User
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleMember = "member"
RoleOwner = shared.RoleOwner
RoleAdmin = shared.RoleAdmin
RoleMember = shared.RoleMember
)
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"`
}
func ValidRole(role string) bool { return shared.ValidRole(role) }
+9 -10
View File
@@ -14,16 +14,16 @@ type InputParam struct {
type WorkflowStep struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
OrgID string `bson:"org_id" json:"org_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
StepID string `bson:"step_id" json:"step_id"`
Name string `bson:"name" json:"name"`
Description string `bson:"description" json:"description"`
Interpreter string `bson:"interpreter" json:"interpreter"`
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"`
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"`
@@ -33,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"`
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"`
@@ -46,7 +46,7 @@ type StepOverride struct {
type Workflow struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
OrgID string `bson:"org_id" json:"org_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
WorkflowID string `bson:"workflow_id" json:"workflow_id"`
Name string `bson:"name" json:"name"`
TargetServerIDs []string `bson:"target_server_ids" json:"target_server_ids"`
@@ -55,7 +55,6 @@ type Workflow struct {
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type ResolvedStep struct {
Order int `bson:"order" json:"order"`
Name string `bson:"name" json:"name"`
@@ -70,7 +69,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"`
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"`
@@ -82,7 +81,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"`
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"`
@@ -91,12 +90,12 @@ type ServerRun struct {
type WorkflowRun struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
OrgID string `bson:"org_id" json:"org_id"`
InstanceID string `bson:"instance_id" json:"instance_id"`
RunID string `bson:"run_id" json:"run_id"`
WorkflowID string `bson:"workflow_id" json:"workflow_id"`
Name string `bson:"name" json:"name"`
Steps []ResolvedStep `bson:"steps_snapshot" json:"steps_snapshot"`
Status string `bson:"status" json:"status"`
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"`
+3 -3
View File
@@ -40,7 +40,7 @@ func loop(ctx context.Context) {
mu.Lock()
defer mu.Unlock()
for id, r := range active {
m, ok := want[id]
if !ok || m.IntervalSec != r.intervalSec {
@@ -48,7 +48,7 @@ func loop(ctx context.Context) {
delete(active, id)
}
}
for id, m := range want {
if _, ok := active[id]; ok {
continue
@@ -86,7 +86,7 @@ func runMonitor(ctx context.Context, m models.Monitor) {
}
}
run()
run()
t := time.NewTicker(interval)
defer t.Stop()
for {
-7
View File
@@ -1,6 +1,3 @@
package notify
import (
@@ -10,7 +7,6 @@ import (
"github.com/mrhid6/vantage/server/internal/models"
)
type Event struct {
MonitorName string
Type string
@@ -20,7 +16,6 @@ type Event struct {
Time time.Time
}
func (e Event) title() string {
verb := "recovered"
if e.NewStatus == models.StatusDown {
@@ -33,7 +28,6 @@ func (e Event) title() string {
return s
}
func Dispatch(ch models.NotificationChannel, ev Event) error {
switch ch.Type {
case models.ChannelWebhook:
@@ -51,7 +45,6 @@ func Dispatch(ch models.NotificationChannel, ev Event) error {
}
}
func Test(ch models.NotificationChannel) error {
return Dispatch(ch, Event{
MonitorName: "Test monitor",
-1
View File
@@ -28,7 +28,6 @@ func postJSON(target string, payload any) error {
return nil
}
func dispatchWebhook(ch models.NotificationChannel, ev Event) error {
target := ch.Config["url"]
if target == "" {
-8
View File
@@ -13,13 +13,6 @@ import (
const smtpTimeout = 15 * time.Second
func dispatchSMTP(ch models.NotificationChannel, ev Event) error {
host := ch.Config["host"]
port := ch.Config["port"]
@@ -36,7 +29,6 @@ func dispatchSMTP(ch models.NotificationChannel, ev Event) error {
}
_ = conn.SetDeadline(time.Now().Add(smtpTimeout))
if port == "465" {
conn = tls.Client(conn, &tls.Config{ServerName: host})
}
-5
View File
@@ -10,7 +10,6 @@ import (
"github.com/mrhid6/vantage/server/internal/models"
)
const (
colBg = "#0f1117"
colSurface = "#1a1d27"
@@ -23,7 +22,6 @@ const (
colDanger = "#ef4444"
)
func statusColor(status string) string {
switch status {
case models.StatusUp:
@@ -35,8 +33,6 @@ func statusColor(status string) string {
}
}
func buildMIME(from, to, subject, text, htmlBody string) ([]byte, error) {
var buf strings.Builder
w := multipart.NewWriter(&buf)
@@ -150,7 +146,6 @@ func htmlEmail(ev Event) string {
)
}
func textEmail(ev Event) string {
return strings.Join([]string{
ev.title(),
+10 -10
View File
@@ -11,25 +11,25 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func LogEvent(orgID, eventType, actor, serverID, keyID, details string) {
func LogEvent(instanceID, 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,
KeyID: keyID,
Details: details,
CreatedAt: time.Now(),
InstanceID: instanceID,
EventType: eventType,
Actor: actor,
ServerID: serverID,
KeyID: keyID,
Details: details,
CreatedAt: time.Now(),
}
if _, err := db.Col("audit_logs").InsertOne(ctx, event); err != nil {
log.Printf("audit log error: %v", err)
}
}
func ListAuditEvents(orgID string, limit int64) ([]models.AuditEvent, error) {
func ListAuditEvents(instanceID string, limit int64) ([]models.AuditEvent, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -37,7 +37,7 @@ func ListAuditEvents(orgID string, 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{"org_id": orgID}, opts)
cursor, err := db.Col("audit_logs").Find(ctx, bson.M{"instance_id": instanceID}, opts)
if err != nil {
return nil, err
}
+16 -20
View File
@@ -13,10 +13,10 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func ListChannels(orgID string) ([]models.NotificationChannel, error) {
func ListChannels(instanceID 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}))
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.M{"created_at": 1}))
if err != nil {
return nil, err
}
@@ -27,11 +27,11 @@ func ListChannels(orgID string) ([]models.NotificationChannel, error) {
return out, nil
}
func GetChannel(orgID, channelID string) (*models.NotificationChannel, error) {
func GetChannel(instanceID, channelID string) (*models.NotificationChannel, error) {
ctx, cancel := monCtx()
defer cancel()
var ch models.NotificationChannel
err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}).Decode(&ch)
err := db.Col("notification_channels").FindOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID}).Decode(&ch)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
@@ -41,14 +41,13 @@ func GetChannel(orgID, channelID string) (*models.NotificationChannel, error) {
return &ch, nil
}
func GetChannels(orgID string, channelIDs []string) ([]models.NotificationChannel, error) {
func GetChannels(instanceID string, channelIDs []string) ([]models.NotificationChannel, error) {
if len(channelIDs) == 0 {
return nil, nil
}
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"org_id": orgID, "channel_id": bson.M{"$in": channelIDs}})
cur, err := db.Col("notification_channels").Find(ctx, bson.M{"instance_id": instanceID, "channel_id": bson.M{"$in": channelIDs}})
if err != nil {
return nil, err
}
@@ -59,11 +58,9 @@ func GetChannels(orgID string, channelIDs []string) ([]models.NotificationChanne
return out, nil
}
func validateChannelIDs(orgID string, channelIDs []string) error {
func validateChannelIDs(instanceID string, channelIDs []string) error {
for _, id := range channelIDs {
ch, err := GetChannel(orgID, id)
ch, err := GetChannel(instanceID, id)
if err != nil {
return err
}
@@ -74,10 +71,10 @@ func validateChannelIDs(orgID string, channelIDs []string) error {
return nil
}
func CreateChannel(orgID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) {
func CreateChannel(instanceID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) {
ctx, cancel := monCtx()
defer cancel()
ch.OrgID = orgID
ch.InstanceID = instanceID
ch.ChannelID = uuid.NewString()
ch.CreatedAt = time.Now()
if ch.Config == nil {
@@ -89,23 +86,22 @@ func CreateChannel(orgID string, ch *models.NotificationChannel) (*models.Notifi
return ch, nil
}
func UpdateChannel(orgID, channelID string, upd bson.M) error {
func UpdateChannel(instanceID, channelID string, upd bson.M) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID}, bson.M{"$set": upd})
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID}, bson.M{"$set": upd})
return err
}
func DeleteChannel(orgID, channelID string) error {
func DeleteChannel(instanceID, channelID string) error {
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID, "org_id": orgID})
_, err := db.Col("notification_channels").DeleteOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID})
return err
}
func TestChannel(orgID, channelID string) error {
ch, err := GetChannel(orgID, channelID)
func TestChannel(instanceID, channelID string) error {
ch, err := GetChannel(instanceID, channelID)
if err != nil {
return err
}
+23 -38
View File
@@ -17,7 +17,7 @@ import (
)
func sessionHMACKey() ([]byte, error) {
k, err := encryptionKey()
if err != nil {
return nil, err
@@ -29,7 +29,6 @@ func sessionHMACKey() ([]byte, error) {
func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
func SignSessionToken(sessionID string, ttl time.Duration) (string, error) {
key, err := sessionHMACKey()
if err != nil {
@@ -42,7 +41,6 @@ func SignSessionToken(sessionID string, ttl time.Duration) (string, error) {
return payload + "." + b64(mac.Sum(nil)), nil
}
func VerifySessionToken(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
@@ -86,10 +84,6 @@ func portOr(v, def int) string {
return strconv.Itoa(v)
}
func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphrase, rdpUser, rdpPass string) (*GuacParams, error) {
host := srv.IPAddress
switch protocol {
@@ -129,19 +123,19 @@ func BuildGuacParams(srv *models.Server, protocol, sshUser, privateKey, passphra
}
}
func CreateConsoleSession(orgID, serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) {
func CreateConsoleSession(instanceID, serverID, protocol, keyID, user, clientIP string) (*models.ConsoleSession, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
s := &models.ConsoleSession{
OrgID: orgID,
SessionID: uuid.NewString(),
ServerID: serverID,
Protocol: protocol,
KeyID: keyID,
User: user,
ClientIP: clientIP,
StartedAt: time.Now(),
InstanceID: instanceID,
SessionID: uuid.NewString(),
ServerID: serverID,
Protocol: protocol,
KeyID: keyID,
User: user,
ClientIP: clientIP,
StartedAt: time.Now(),
}
if _, err := db.Col("console_sessions").InsertOne(ctx, s); err != nil {
return nil, err
@@ -149,19 +143,17 @@ func CreateConsoleSession(orgID, serverID, protocol, keyID, user, clientIP strin
return s, nil
}
func GetConsoleSession(orgID, sessionID string) (*models.ConsoleSession, error) {
func GetConsoleSession(instanceID, sessionID string) (*models.ConsoleSession, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var s models.ConsoleSession
if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID, "org_id": orgID}).Decode(&s); err != nil {
if err := db.Col("console_sessions").FindOne(ctx, bson.M{"session_id": sessionID, "instance_id": instanceID}).Decode(&s); err != nil {
return nil, err
}
return &s, nil
}
func StashConsoleRDPCreds(orgID, sessionID, username, password string) error {
func StashConsoleRDPCreds(instanceID, sessionID, username, password string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
u, err := encryptString(username)
@@ -173,17 +165,14 @@ func StashConsoleRDPCreds(orgID, sessionID, username, password string) error {
return err
}
_, err = db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID, "org_id": orgID},
bson.M{"session_id": sessionID, "instance_id": instanceID},
bson.M{"$set": bson.M{"rdp_user_enc": u, "rdp_pass_enc": p}},
)
return err
}
func ConsumeConsoleRDPCreds(orgID, sessionID string) (username, password string, err error) {
s, err := GetConsoleSession(orgID, sessionID)
func ConsumeConsoleRDPCreds(instanceID, sessionID string) (username, password string, err error) {
s, err := GetConsoleSession(instanceID, sessionID)
if err != nil {
return "", "", err
}
@@ -203,31 +192,27 @@ func ConsumeConsoleRDPCreds(orgID, sessionID string) (username, password string,
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _ = db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID, "org_id": orgID},
bson.M{"session_id": sessionID, "instance_id": instanceID},
bson.M{"$unset": bson.M{"rdp_user_enc": "", "rdp_pass_enc": ""}},
)
return username, password, nil
}
func SetConsoleSSHUser(orgID, sessionID, username string) error {
func SetConsoleSSHUser(instanceID, sessionID, username string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID, "org_id": orgID},
bson.M{"session_id": sessionID, "instance_id": instanceID},
bson.M{"$set": bson.M{"ssh_username": username}})
return err
}
func ConsumeSessionToken(orgID, sessionID string) error {
func ConsumeSessionToken(instanceID, sessionID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
res, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID, "org_id": orgID, "token_consumed_at": nil},
bson.M{"session_id": sessionID, "instance_id": instanceID, "token_consumed_at": nil},
bson.M{"$set": bson.M{"token_consumed_at": now}},
)
if err != nil {
@@ -239,12 +224,12 @@ func ConsumeSessionToken(orgID, sessionID string) error {
return nil
}
func EndConsoleSession(orgID, sessionID string) error {
func EndConsoleSession(instanceID, sessionID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
_, err := db.Col("console_sessions").UpdateOne(ctx,
bson.M{"session_id": sessionID, "org_id": orgID, "ended_at": nil},
bson.M{"session_id": sessionID, "instance_id": instanceID, "ended_at": nil},
bson.M{"$set": bson.M{"ended_at": now}},
)
return err
+41
View File
@@ -0,0 +1,41 @@
package services
import (
"context"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/indexes"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// EnsureAuthIndexes declares the indexes tenant isolation depends on.
//
// It lives here rather than in migrate.go so that the org-to-instance rename
// could touch it without touching migrations 0001 to 0003, which deliberately
// still speak the pre-rename shape.
//
// It MUST run after MigrateOrgToInstance. Creating the instances.slug index
// first would create an empty instances collection, and migration 0004 refuses
// to rename orgs when instances already exists.
func EnsureAuthIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// users.email and instances.slug are declared in the shared module so the
// control plane and sitesvc cannot disagree about them.
if err := indexes.EnsureCoreIndexes(ctx, db.Database); err != nil {
return err
}
// instance_oidc is control-plane only, so its index stays here.
if _, err := db.Col("instance_oidc").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "instance_id", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return err
}
return nil
}
-3
View File
@@ -22,8 +22,6 @@ func encryptionKey() ([]byte, error) {
return key, nil
}
func encryptString(plaintext string) (string, error) {
key, err := encryptionKey()
if err != nil {
@@ -45,7 +43,6 @@ func encryptString(plaintext string) (string, error) {
return hex.EncodeToString(sealed), nil
}
func decryptString(ciphertextHex string) (string, error) {
key, err := encryptionKey()
if err != nil {
+9 -14
View File
@@ -8,11 +8,11 @@ import (
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func DefaultStepsDir() string {
dir := os.Getenv("VANTAGE_DEFAULT_STEPS_DIR")
if dir == "" {
@@ -22,9 +22,6 @@ func DefaultStepsDir() string {
return dir
}
func readDefaultStepFiles() ([]models.WorkflowStep, error) {
matches, err := filepath.Glob(filepath.Join(DefaultStepsDir(), "*.json"))
if err != nil {
@@ -41,7 +38,7 @@ func readDefaultStepFiles() ([]models.WorkflowStep, error) {
continue
}
s.Source = "default"
s.Slug = Slugify(s.Name)
s.Slug = provision.Slugify(s.Name)
if s.Slug == "" {
continue
}
@@ -50,9 +47,7 @@ func readDefaultStepFiles() ([]models.WorkflowStep, error) {
return out, nil
}
func SeedDefaultSteps(orgID string) (created, updated int, err error) {
func SeedDefaultSteps(instanceID string) (created, updated int, err error) {
steps, err := readDefaultStepFiles()
if err != nil {
return 0, 0, err
@@ -61,7 +56,7 @@ func SeedDefaultSteps(orgID string) (created, updated int, err error) {
defer cancel()
col := db.Col("workflow_steps")
for _, s := range steps {
filter := bson.M{"org_id": orgID, "slug": s.Slug, "source": "default"}
filter := bson.M{"instance_id": instanceID, "slug": s.Slug, "source": "default"}
set := bson.M{
"name": s.Name,
"description": s.Description,
@@ -75,11 +70,11 @@ func SeedDefaultSteps(orgID string) (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",
"created_at": time.Now(),
"instance_id": instanceID,
"step_id": uuid.New().String(),
"slug": s.Slug,
"source": "default",
"created_at": time.Now(),
},
}, options.UpdateOne().SetUpsert(true))
if uerr != nil {
+2 -22
View File
@@ -17,13 +17,10 @@ type commandDispatcher struct {
channels map[string]chan *pb.ServerCommand
}
var Dispatcher = &commandDispatcher{
channels: make(map[string]chan *pb.ServerCommand),
}
func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand {
ch := make(chan *pb.ServerCommand, 16)
d.mu.Lock()
@@ -32,14 +29,12 @@ func (d *commandDispatcher) Connect(serverID string) chan *pb.ServerCommand {
return ch
}
func (d *commandDispatcher) Disconnect(serverID string) {
d.mu.Lock()
delete(d.channels, serverID)
d.mu.Unlock()
}
func (d *commandDispatcher) IsConnected(serverID string) bool {
d.mu.RLock()
_, ok := d.channels[serverID]
@@ -62,15 +57,10 @@ func (d *commandDispatcher) dispatch(serverID string, cmd *pb.ServerCommand) err
}
}
func DispatchRunStep(serverID, commandID string, cmd *pb.RunStepCmd) error {
return Dispatcher.dispatch(serverID, &pb.ServerCommand{CommandId: commandID, RunStep: cmd})
}
func DispatchCleanupWorkspace(serverID, workspaceID string) {
if !Dispatcher.IsConnected(serverID) {
return
@@ -81,7 +71,6 @@ func DispatchCleanupWorkspace(serverID, workspaceID string) {
})
}
type KeyGenParams struct {
Label string
KeyType string
@@ -90,15 +79,13 @@ type KeyGenParams struct {
Comment string
}
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)
resp, err := http.Get(url)
if err != nil {
return "", fmt.Errorf("fetch releases: %w", err)
}
@@ -122,8 +109,6 @@ func GetLatestAgentVersion() (string, error) {
return "", fmt.Errorf("no agent release found")
}
func DispatchUpdateAgent(serverID string) (string, error) {
if !Dispatcher.IsConnected(serverID) {
return "", fmt.Errorf("agent is not connected to the command stream")
@@ -153,7 +138,6 @@ func DispatchUpdateAgent(serverID string) (string, error) {
return version, nil
}
func DispatchApplyUpdates(serverID string) error {
if !Dispatcher.IsConnected(serverID) {
return fmt.Errorf("agent is not connected to the command stream")
@@ -165,8 +149,6 @@ func DispatchApplyUpdates(serverID string) error {
return Dispatcher.dispatch(serverID, cmd)
}
func DispatchDeleteKey(serverID, label string) {
if !Dispatcher.IsConnected(serverID) {
return
@@ -176,13 +158,11 @@ func DispatchDeleteKey(serverID, label string) {
DeleteKey: &pb.DeleteKeyCmd{Label: label},
}
if err := Dispatcher.dispatch(serverID, cmd); err != nil {
_ = err
}
}
func DispatchGenerateKey(serverID string, p KeyGenParams) (string, error) {
if !Dispatcher.IsConnected(serverID) {
return "", fmt.Errorf("agent is not connected to the command stream")
@@ -10,32 +10,30 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func GetOrgOIDC(orgID string) (*models.OrgOIDC, error) {
func GetInstanceOIDC(instanceID 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)
err := db.Col("instance_oidc").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&o)
if err != nil {
return nil, err
}
return &o, nil
}
func GetOrgOIDCSecret(orgID string) (string, error) {
o, err := GetOrgOIDC(orgID)
func GetInstanceOIDCSecret(instanceID string) (string, error) {
o, err := GetInstanceOIDC(instanceID)
if err != nil {
return "", err
}
return decryptString(o.ClientSecretEnc)
}
func SaveOrgOIDC(orgID, issuer, clientID, clientSecret string, enabled bool) error {
func SaveOrgOIDC(instanceID, 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,
"instance_id": instanceID, "issuer": issuer, "client_id": clientID,
"enabled": enabled, "updated_at": time.Now(),
}
if clientSecret != "" {
@@ -45,8 +43,8 @@ func SaveOrgOIDC(orgID, issuer, clientID, clientSecret string, enabled bool) err
}
set["client_secret_enc"] = enc
}
_, err := db.Col("org_oidc").UpdateOne(ctx,
bson.M{"org_id": orgID}, bson.M{"$set": set},
_, err := db.Col("instance_oidc").UpdateOne(ctx,
bson.M{"instance_id": instanceID}, bson.M{"$set": set},
options.UpdateOne().SetUpsert(true))
return err
}
+122
View File
@@ -0,0 +1,122 @@
package services
import (
"context"
"fmt"
"log"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
func GetInstance(instanceID string) (*models.Instance, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.Instance
err := db.Col("instances").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&o)
if err != nil {
return nil, err
}
return &o, nil
}
func GetInstanceBySlug(slug string) (*models.Instance, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.Instance
err := db.Col("instances").FindOne(ctx, bson.M{"slug": slug}).Decode(&o)
if err != nil {
return nil, err
}
return &o, nil
}
func ListInstanceIDs() ([]string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("instances").Find(ctx, bson.M{})
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var orgs []models.Instance
if err := cursor.All(ctx, &orgs); err != nil {
return nil, err
}
ids := make([]string, 0, len(orgs))
for _, o := range orgs {
ids = append(ids, o.InstanceID)
}
return ids, nil
}
func CountInstances() (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return db.Col("instances").CountDocuments(ctx, bson.M{})
}
func FirstInstance() (*models.Instance, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.Instance
if err := db.Col("instances").FindOne(ctx, bson.M{}).Decode(&o); err != nil {
return nil, err
}
return &o, nil
}
func AdoptInstance(instanceID, name string) (*models.Instance, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
set := bson.M{"name": name}
slug := provision.Slugify(name)
if len(slug) > provision.MaxSlugLength {
slug = slug[:provision.MaxSlugLength]
}
if len(slug) >= provision.MinSlugLength && !provision.ReservedSlugs[slug] {
n, err := db.Col("instances").CountDocuments(ctx, bson.M{"slug": slug, "instance_id": bson.M{"$ne": instanceID}})
if err != nil {
return nil, err
}
if n == 0 {
set["slug"] = slug
}
}
if _, err := db.Col("instances").UpdateOne(ctx, bson.M{"instance_id": instanceID}, bson.M{"$set": set}); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, fmt.Errorf("organization slug already taken")
}
return nil, err
}
return GetInstance(instanceID)
}
// CreateInstance creates an organisation and seeds its default workflow steps.
//
// The creation rules live in shared/provision because sitesvc creates
// organisations too. Seeding stays here: shared must not know about workflow
// steps.
func CreateInstance(name string) (*models.Instance, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
o, err := provision.CreateInstance(ctx, db.Database, name)
if err != nil {
return nil, err
}
if created, updated, err := SeedDefaultSteps(o.InstanceID); err != nil {
log.Printf("warning: failed to seed default steps for new org %s: %v", o.InstanceID, err)
} else {
log.Printf("default steps seeded for new org %s: %d created, %d updated", o.InstanceID, created, updated)
}
return o, nil
}
-2
View File
@@ -9,8 +9,6 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
)
func StoreInventory(serverID string, r *pb.InventoryReport) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+33 -34
View File
@@ -36,9 +36,9 @@ func setKeyMeta(k *models.Key) {
k.HasPassphrase = k.PassphraseEncrypted != ""
}
func CreateKey(orgID, label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
func CreateKey(instanceID, label, publicKey, source, generatedByServerID, privateKey, passphrase string) (*models.Key, error) {
key := &models.Key{
OrgID: orgID,
InstanceID: instanceID,
KeyID: uuid.NewString(),
Label: label,
PublicKey: publicKey,
@@ -72,12 +72,12 @@ func CreateKey(orgID, label, publicKey, source, generatedByServerID, privateKey,
return key, nil
}
func GetKey(orgID, keyID string) (*models.Key, error) {
func GetKey(instanceID, keyID string) (*models.Key, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var key models.Key
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key)
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key)
if err != nil {
return nil, err
}
@@ -85,12 +85,12 @@ func GetKey(orgID, keyID string) (*models.Key, error) {
return &key, nil
}
func GetPrivateKey(orgID, keyID string) (string, error) {
func GetPrivateKey(instanceID, keyID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key); err != nil {
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key); err != nil {
return "", err
}
if key.PrivateKeyEncrypted == "" {
@@ -118,11 +118,11 @@ type KeyWithCount struct {
AssignedCount int `bson:"-" json:"assigned_count"`
}
func ListKeys(orgID string) ([]KeyWithCount, error) {
func ListKeys(instanceID string) ([]KeyWithCount, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("keys").Find(ctx, bson.M{"org_id": orgID})
cursor, err := db.Col("keys").Find(ctx, bson.M{"instance_id": instanceID})
if err != nil {
return nil, err
}
@@ -137,28 +137,28 @@ func ListKeys(orgID string) ([]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,
"instance_id": instanceID,
"key_id": k.KeyID,
"revoked_at": nil,
})
result = append(result, KeyWithCount{Key: k, AssignedCount: int(count)})
}
return result, nil
}
func DeleteKey(orgID, keyID string) error {
func DeleteKey(instanceID, keyID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var key models.Key
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}).Decode(&key); err != nil {
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}).Decode(&key); err != nil {
return err
}
if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID, "org_id": orgID}); err != nil {
if _, err := db.Col("keys").DeleteOne(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}); err != nil {
return err
}
if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID, "org_id": orgID}); err != nil {
if _, err := db.Col("assignments").DeleteMany(ctx, bson.M{"key_id": keyID, "instance_id": instanceID}); err != nil {
return err
}
@@ -168,31 +168,30 @@ func DeleteKey(orgID, keyID string) error {
return nil
}
func AssignKey(orgID, keyID, serverID string) (*models.Assignment, error) {
func AssignKey(instanceID, keyID, serverID string) (*models.Assignment, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := GetKey(orgID, keyID); err != nil {
if _, err := GetKey(instanceID, keyID); err != nil {
return nil, fmt.Errorf("key not found")
}
if _, err := GetServer(orgID, serverID); err != nil {
if _, err := GetServer(instanceID, serverID); err != nil {
return nil, fmt.Errorf("server not found")
}
var existing models.Assignment
err := db.Col("assignments").FindOne(ctx, bson.M{
"org_id": orgID,
"key_id": keyID,
"server_id": serverID,
"revoked_at": nil,
"instance_id": instanceID,
"key_id": keyID,
"server_id": serverID,
"revoked_at": nil,
}).Decode(&existing)
if err == nil {
return &existing, nil
}
a := &models.Assignment{
OrgID: orgID,
InstanceID: instanceID,
KeyID: keyID,
ServerID: serverID,
AssignedAt: time.Now(),
@@ -204,23 +203,23 @@ func AssignKey(orgID, keyID, serverID string) (*models.Assignment, error) {
return a, nil
}
func RevokeAssignment(orgID, keyID, serverID string) error {
func RevokeAssignment(instanceID, keyID, serverID string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now()
_, err := db.Col("assignments").UpdateOne(ctx,
bson.M{"org_id": orgID, "key_id": keyID, "server_id": serverID, "revoked_at": nil},
bson.M{"instance_id": instanceID, "key_id": keyID, "server_id": serverID, "revoked_at": nil},
bson.M{"$set": bson.M{"revoked_at": now}},
)
return err
}
func GetAssignmentsForKey(orgID, keyID string) ([]models.Assignment, error) {
func GetAssignmentsForKey(instanceID, keyID string) ([]models.Assignment, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "key_id": keyID, "revoked_at": nil})
cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "key_id": keyID, "revoked_at": nil})
if err != nil {
return nil, err
}
@@ -238,11 +237,11 @@ type AssignmentWithServer struct {
Server *models.Server `json:"server,omitempty"`
}
func GetAssignmentsWithServers(orgID, keyID string) ([]AssignmentWithServer, error) {
func GetAssignmentsWithServers(instanceID, keyID string) ([]AssignmentWithServer, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "key_id": keyID})
cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "key_id": keyID})
if err != nil {
return nil, err
}
@@ -257,7 +256,7 @@ func GetAssignmentsWithServers(orgID, keyID string) ([]AssignmentWithServer, err
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, "org_id": orgID}).Decode(&srv); err == nil {
if err := db.Col("servers").FindOne(ctx, bson.M{"server_id": a.ServerID, "instance_id": instanceID}).Decode(&srv); err == nil {
item.Server = &srv
}
result = append(result, item)
@@ -270,11 +269,11 @@ type AssignmentWithKey struct {
Key *models.Key `json:"key,omitempty"`
}
func GetAssignmentsWithKeysForServer(orgID, serverID string) ([]AssignmentWithKey, error) {
func GetAssignmentsWithKeysForServer(instanceID, serverID string) ([]AssignmentWithKey, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cursor, err := db.Col("assignments").Find(ctx, bson.M{"org_id": orgID, "server_id": serverID})
cursor, err := db.Col("assignments").Find(ctx, bson.M{"instance_id": instanceID, "server_id": serverID})
if err != nil {
return nil, err
}
@@ -288,7 +287,7 @@ func GetAssignmentsWithKeysForServer(orgID, serverID string) ([]AssignmentWithKe
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, "org_id": orgID}).Decode(&key); err != nil {
if err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "instance_id": instanceID}).Decode(&key); err != nil {
continue
}
setKeyMeta(&key)
+20 -37
View File
@@ -8,51 +8,36 @@ import (
"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{
// legacyOrg is the pre-0004 shape of the orgs collection.
//
// Migrations 0001 to 0003 run BEFORE the org-to-instance rename and must keep
// reading and writing org_id in the orgs collection. They deliberately do not
// use shared/models, which has moved on to Instance and instance_id.
type legacyOrg struct {
OrgID string `bson:"org_id"`
Name string `bson:"name"`
Slug string `bson:"slug"`
CreatedAt time.Time `bson:"created_at"`
}
var backfillCollections = []string{
"servers", "keys", "assignments", "secrets",
"workflows", "workflow_steps", "workflow_runs",
"audit_logs", "monitors", "notification_channels",
"console_sessions", "incidents", "monitor_rollups",
}
func 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
func defaultBackfillOrg(ctx context.Context) (*legacyOrg, error) {
var org legacyOrg
err := db.Col("orgs").FindOne(ctx, bson.M{"slug": "default"}).Decode(&org)
switch {
case err == nil:
case errors.Is(err, mongo.ErrNoDocuments):
org = models.Org{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()}
org = legacyOrg{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()}
if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil {
return nil, err
}
@@ -71,9 +56,8 @@ func RunMigrations() error {
return nil
}
needs := false
for _, col := range scopedCollections {
for _, col := range backfillCollections {
n, _ := db.Col(col).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}})
if n > 0 {
needs = true
@@ -86,7 +70,7 @@ func RunMigrations() error {
if err != nil {
return err
}
for _, col := range scopedCollections {
for _, col := range backfillCollections {
if _, err := db.Col(col).UpdateMany(ctx,
bson.M{"org_id": bson.M{"$exists": false}},
bson.M{"$set": bson.M{"org_id": org.OrgID}},
@@ -109,7 +93,6 @@ func MigrateMissedOrgScopes() error {
return nil
}
missed := []string{"audit_logs", "notification_channels"}
needs := false
for _, col := range missed {
@@ -190,7 +173,7 @@ func MigrateSettingsOrg() error {
n, _ := db.Col("settings").CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": false}})
if n > 0 {
var org models.Org
var org legacyOrg
orgCount, err := db.Col("orgs").CountDocuments(ctx, bson.M{})
if err != nil {
return err
@@ -201,7 +184,7 @@ func MigrateSettingsOrg() error {
return err
}
case 0:
org = models.Org{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()}
org = legacyOrg{OrgID: uuid.NewString(), Name: "Default", Slug: "default", CreatedAt: time.Now()}
if _, err := db.Col("orgs").InsertOne(ctx, org); err != nil {
return err
}
@@ -0,0 +1,239 @@
package services
import (
"context"
"fmt"
"log"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// ScopedCollections lists every collection carrying the tenant key.
//
// Migration 0004 renames org_id to instance_id in each. A collection missing
// from this list keeps the old field name and becomes invisible to every scoped
// query — so this list is load-bearing, not documentation.
//
// AssertNoScopedCollectionMissed checks at boot that nothing outside this list
// holds an org_id.
//
// The migrations collection is deliberately absent: it is not tenant-scoped.
// The two renamed collections appear under their post-rename names, because the
// migration renames the collections before it renames the field.
var ScopedCollections = []string{
"instances",
"servers",
"keys",
"assignments",
"users",
"instance_oidc",
"settings",
"secrets",
"workflows",
"workflow_steps",
"workflow_runs",
"monitors",
"incidents",
"monitor_rollups",
"notification_channels",
"console_sessions",
"audit_logs",
}
// collectionRenames maps the two collections whose names change. Ordered so the
// migration is deterministic.
var collectionRenames = []struct{ from, to string }{
{"orgs", "instances"},
{"org_oidc", "instance_oidc"},
}
// MigrateOrgToInstance renames the tenant key from org_id to instance_id.
//
// It only ever renames documents. It never deletes, drops or unsets one, so a
// bad deploy is recovered by running the inverse rename (cmd/rename-rollback)
// rather than by restoring a backup.
//
// The steps are not atomic across collections — multi-document transactions
// would require a replica set, which self-hosted installs do not guarantee.
// Instead every step is safely repeatable: a collection rename is skipped when
// the source is already gone, and $rename matches nothing on a document that
// has already been renamed. A run that fails partway is fixed by running it
// again.
//
// It must run BEFORE EnsureAuthIndexes. Creating the instances.slug index first
// would create an empty instances collection, and step 1 below refuses to
// rename orgs onto an existing target.
func MigrateOrgToInstance(ctx context.Context, db *mongo.Database) error {
names, err := db.ListCollectionNames(ctx, bson.M{})
if err != nil {
return fmt.Errorf("list collections: %w", err)
}
exists := map[string]bool{}
for _, n := range names {
exists[n] = true
}
// Step 1: rename the collections.
for _, r := range collectionRenames {
switch {
case !exists[r.from]:
// Nothing to rename: either already done or never existed.
continue
case exists[r.to]:
return fmt.Errorf("cannot rename %s to %s: both exist; resolve by hand", r.from, r.to)
}
cmd := bson.D{
{Key: "renameCollection", Value: db.Name() + "." + r.from},
{Key: "to", Value: db.Name() + "." + r.to},
}
if err := db.Client().Database("admin").RunCommand(ctx, cmd).Err(); err != nil {
return fmt.Errorf("rename %s to %s: %w", r.from, r.to, err)
}
log.Printf("0004: renamed collection %s to %s", r.from, r.to)
}
// Step 2: drop indexes keyed on the old field name, BEFORE renaming it.
//
// Order matters and is not obvious. A unique index on org_id treats a
// missing org_id as null, so as soon as $rename strips the field from the
// second document the index reports a duplicate null and the whole update
// fails. Dropping first avoids that entirely.
//
// Dropping an index touches no documents. The boot-time index builders
// recreate the current ones against the new field name.
for _, c := range ScopedCollections {
if err := DropIndexesKeyedOn(ctx, db, c, "org_id"); err != nil {
return err
}
}
// Step 3: rename the field.
for _, c := range ScopedCollections {
res, err := db.Collection(c).UpdateMany(ctx,
bson.M{"org_id": bson.M{"$exists": true}},
bson.M{"$rename": bson.M{"org_id": "instance_id"}},
)
if err != nil {
return fmt.Errorf("rename org_id in %s: %w", c, err)
}
if res.ModifiedCount > 0 {
log.Printf("0004: %s renamed %d document(s)", c, res.ModifiedCount)
}
}
// Step 4: verify before anyone records a marker. Any mismatch aborts, and
// the migration is re-run rather than marked done.
for _, c := range ScopedCollections {
total, err := db.Collection(c).CountDocuments(ctx, bson.M{})
if err != nil {
return fmt.Errorf("count %s: %w", c, err)
}
if total == 0 {
continue
}
stale, err := db.Collection(c).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}})
if err != nil {
return fmt.Errorf("count stale in %s: %w", c, err)
}
if stale != 0 {
return fmt.Errorf("%s still has %d document(s) with org_id; migration incomplete", c, stale)
}
scoped, err := db.Collection(c).CountDocuments(ctx, bson.M{"instance_id": bson.M{"$exists": true}})
if err != nil {
return fmt.Errorf("count scoped in %s: %w", c, err)
}
if scoped != total {
return fmt.Errorf("%s has %d document(s) but only %d carry instance_id", c, total, scoped)
}
}
log.Printf("0004: verified %d collection(s)", len(ScopedCollections))
return nil
}
// IndexKeyedOn reports whether an index specification's key document mentions
// field.
//
// The key is checked as both bson.D and bson.M because the driver's decoding of
// a nested document depends on the target type, and getting this wrong is
// silent: the index simply is not found, and the field rename then fails on a
// duplicate null.
func IndexKeyedOn(key any, field string) bool {
switch k := key.(type) {
case bson.M:
_, ok := k[field]
return ok
case bson.D:
for _, e := range k {
if e.Key == field {
return true
}
}
}
return false
}
// DropIndexesKeyedOn removes every index on coll whose key mentions field,
// leaving _id_ alone.
//
// Both the migration and its rollback must do this BEFORE renaming the field. A
// unique index treats a missing field as null, so as soon as $rename strips the
// field from the second document the index reports a duplicate null and the
// whole update fails. Dropping an index touches no documents; the boot-time
// index builders recreate what is needed.
func DropIndexesKeyedOn(ctx context.Context, db *mongo.Database, coll, field string) error {
cur, err := db.Collection(coll).Indexes().List(ctx)
if err != nil {
return fmt.Errorf("list indexes on %s: %w", coll, err)
}
var specs []bson.M
if err := cur.All(ctx, &specs); err != nil {
return fmt.Errorf("decode indexes on %s: %w", coll, err)
}
for _, s := range specs {
name, _ := s["name"].(string)
if name == "_id_" {
continue
}
if !IndexKeyedOn(s["key"], field) {
continue
}
if err := db.Collection(coll).Indexes().DropOne(ctx, name); err != nil {
return fmt.Errorf("drop index %s on %s: %w", name, coll, err)
}
log.Printf("dropped stale index %s on %s", name, coll)
}
return nil
}
// AssertNoScopedCollectionMissed reports any collection holding an org_id that
// ScopedCollections does not know about. A hit means a collection was added
// without being added to the list, and its tenant key was never renamed.
func AssertNoScopedCollectionMissed(ctx context.Context, db *mongo.Database) error {
known := map[string]bool{}
for _, c := range ScopedCollections {
known[c] = true
}
names, err := db.ListCollectionNames(ctx, bson.M{})
if err != nil {
return fmt.Errorf("list collections: %w", err)
}
for _, n := range names {
if known[n] {
continue
}
count, err := db.Collection(n).CountDocuments(ctx, bson.M{"org_id": bson.M{"$exists": true}})
if err != nil {
return fmt.Errorf("count %s: %w", n, err)
}
if count > 0 {
return fmt.Errorf("collection %q holds %d document(s) with org_id but is not in ScopedCollections", n, count)
}
}
return nil
}
+42 -54
View File
@@ -21,7 +21,6 @@ func monCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
func SpecFor(m *models.Monitor) checker.Spec {
return checker.Spec{
Type: m.Type,
@@ -37,10 +36,10 @@ func SpecFor(m *models.Monitor) checker.Spec {
}
}
func ListMonitors(orgID string) ([]models.Monitor, error) {
func ListMonitors(instanceID string) ([]models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("monitors").Find(ctx, bson.M{"org_id": orgID}, options.Find().SetSort(bson.M{"created_at": 1}))
cur, err := db.Col("monitors").Find(ctx, bson.M{"instance_id": instanceID}, options.Find().SetSort(bson.M{"created_at": 1}))
if err != nil {
return nil, err
}
@@ -51,23 +50,23 @@ func ListMonitors(orgID string) ([]models.Monitor, error) {
return out, nil
}
func ListMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) {
if orgID == "" {
func ListMonitorsForRunner(instanceID, runner string) ([]models.Monitor, error) {
if instanceID == "" {
return nil, errors.New("org id required")
}
return listMonitorsForRunner(orgID, runner)
return listMonitorsForRunner(instanceID, runner)
}
func ListServerScheduledMonitors() ([]models.Monitor, error) {
return listMonitorsForRunner("", models.RunnerServer)
}
func listMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) {
func listMonitorsForRunner(instanceID, runner string) ([]models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
filter := bson.M{"runner": runner, "enabled": true}
if orgID != "" {
filter["org_id"] = orgID
if instanceID != "" {
filter["instance_id"] = instanceID
}
cur, err := db.Col("monitors").Find(ctx, filter)
if err != nil {
@@ -80,12 +79,11 @@ func listMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) {
return out, nil
}
func GetMonitor(orgID, monitorID string) (*models.Monitor, error) {
func GetMonitor(instanceID, 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)
err := db.Col("monitors").FindOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID}).Decode(&m)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, nil
}
@@ -109,26 +107,26 @@ func getMonitorByID(monitorID string) (*models.Monitor, error) {
return &m, nil
}
func validateRunner(orgID, runner string) error {
func validateRunner(instanceID, runner string) error {
if runner == "" || runner == models.RunnerServer {
return nil
}
if _, err := GetServer(orgID, runner); err != nil {
if _, err := GetServer(instanceID, runner); err != nil {
return fmt.Errorf("runner server %s not found", runner)
}
return nil
}
func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) {
func CreateMonitor(instanceID string, m *models.Monitor) (*models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
if err := validateChannelIDs(orgID, m.ChannelIDs); err != nil {
if err := validateChannelIDs(instanceID, m.ChannelIDs); err != nil {
return nil, err
}
if err := validateRunner(orgID, m.Runner); err != nil {
if err := validateRunner(instanceID, m.Runner); err != nil {
return nil, err
}
m.OrgID = orgID
m.InstanceID = instanceID
m.MonitorID = uuid.NewString()
m.CreatedAt = time.Now()
if m.IntervalSec <= 0 {
@@ -147,17 +145,16 @@ func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) {
return m, nil
}
func UpdateMonitor(orgID, monitorID string, upd bson.M) error {
func UpdateMonitor(instanceID, monitorID string, upd bson.M) error {
ctx, cancel := monCtx()
defer cancel()
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 {
if err := validateChannelIDs(instanceID, ids); err != nil {
return err
}
}
@@ -166,43 +163,41 @@ func UpdateMonitor(orgID, monitorID string, upd bson.M) error {
if !ok {
return fmt.Errorf("runner must be a string")
}
if err := validateRunner(orgID, runner); err != nil {
if err := validateRunner(instanceID, 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})
_, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID}, bson.M{"$set": upd})
return err
}
func DeleteMonitor(orgID, monitorID string) error {
func DeleteMonitor(instanceID, monitorID string) error {
ctx, cancel := monCtx()
defer cancel()
res, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID})
res, err := db.Col("monitors").DeleteOne(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
if err != nil {
return err
}
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})
db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
return nil
}
func ListIncidents(orgID, monitorID string, limit int64) ([]models.Incident, error) {
func ListIncidents(instanceID, 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, "org_id": orgID},
cur, err := db.Col("incidents").Find(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID},
options.Find().SetSort(bson.M{"started_at": -1}).SetLimit(limit))
if err != nil {
return nil, err
@@ -214,12 +209,11 @@ func ListIncidents(orgID, monitorID string, limit int64) ([]models.Incident, err
return out, nil
}
func UptimeRollups(orgID, monitorID string, since time.Time) ([]models.Rollup, error) {
func UptimeRollups(instanceID, 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, "org_id": orgID, "period_start": bson.M{"$gte": since}},
bson.M{"monitor_id": monitorID, "instance_id": instanceID, "period_start": bson.M{"$gte": since}},
options.Find().SetSort(bson.M{"period_start": 1}))
if err != nil {
return nil, err
@@ -231,18 +225,18 @@ func UptimeRollups(orgID, monitorID string, since time.Time) ([]models.Rollup, e
return out, nil
}
func IngestResult(orgID, runner, monitorID string, res checker.Result) error {
if orgID == "" {
func IngestResult(instanceID, runner, monitorID string, res checker.Result) error {
if instanceID == "" {
return errors.New("org id required")
}
return ingestResult(orgID, runner, monitorID, res)
return ingestResult(instanceID, 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 {
func ingestResult(instanceID, runner, monitorID string, res checker.Result) error {
ctx, cancel := monCtx()
defer cancel()
@@ -253,7 +247,7 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error {
if m == nil {
return fmt.Errorf("monitor %s not found", monitorID)
}
if orgID != "" && m.OrgID != orgID {
if instanceID != "" && m.InstanceID != instanceID {
return fmt.Errorf("monitor %s belongs to another org", monitorID)
}
if m.Runner != runner {
@@ -295,28 +289,25 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error {
return err
}
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)},
"$setOnInsert": bson.M{"org_id": m.OrgID},
"$setOnInsert": bson.M{"instance_id": m.InstanceID},
},
options.UpdateOne().SetUpsert(true))
if newStatus != prev {
switch newStatus {
case models.StatusDown:
inc := models.Incident{
OrgID: m.OrgID,
InstanceID: m.InstanceID,
IncidentID: uuid.NewString(),
MonitorID: monitorID,
StartedAt: now,
@@ -327,7 +318,7 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error {
case models.StatusUp:
if prev == models.StatusDown {
db.Col("incidents").UpdateOne(ctx,
bson.M{"monitor_id": monitorID, "org_id": m.OrgID, "resolved_at": nil},
bson.M{"monitor_id": monitorID, "instance_id": m.InstanceID, "resolved_at": nil},
bson.M{"$set": bson.M{"resolved_at": now}})
notifyTransition(m, newStatus, res.Message)
}
@@ -336,14 +327,11 @@ func ingestResult(orgID, runner, monitorID string, res checker.Result) error {
return nil
}
func notifyTransition(m *models.Monitor, newStatus, message string) {
if len(m.ChannelIDs) == 0 {
return
}
channels, err := GetChannels(m.OrgID, m.ChannelIDs)
channels, err := GetChannels(m.InstanceID, m.ChannelIDs)
if err != nil {
log.Printf("notify: load channels for %s: %v", m.MonitorID, err)
return
@@ -366,5 +354,5 @@ func notifyTransition(m *models.Monitor, newStatus, message string) {
}
}(ch)
}
_ = UpdateMonitor(m.OrgID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()})
_ = UpdateMonitor(m.InstanceID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()})
}
-157
View File
@@ -1,157 +0,0 @@
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
}
+16 -19
View File
@@ -23,7 +23,7 @@ func EnsureSecretIndexes() error {
}
_, err := db.Col("secrets").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "org_id", Value: 1}, {Key: "group", Value: 1}, {Key: "key", Value: 1}},
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "group", Value: 1}, {Key: "key", Value: 1}},
Options: options.Index().SetUnique(true),
})
return err
@@ -38,12 +38,12 @@ func isIndexNotFound(err error) bool {
return false
}
func ListSecretGroups(orgID string) ([]models.GroupSummary, error) {
func ListSecretGroups(instanceID 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: "$match", Value: bson.D{{Key: "instance_id", Value: instanceID}}}},
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$group"},
{Key: "key_count", Value: bson.D{{Key: "$sum", Value: 1}}},
@@ -78,11 +78,11 @@ func ListSecretGroups(orgID string) ([]models.GroupSummary, error) {
return groups, nil
}
func GetSecretGroup(orgID, group string) ([]models.Secret, error) {
func GetSecretGroup(instanceID, 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{"org_id": orgID, "group": group},
cursor, err := db.Col("secrets").Find(ctx, bson.M{"instance_id": instanceID, "group": group},
options.Find().SetSort(bson.D{{Key: "key", Value: 1}}))
if err != nil {
return nil, err
@@ -96,8 +96,8 @@ func GetSecretGroup(orgID, group string) ([]models.Secret, error) {
return docs, nil
}
func GetSecretGroupDecrypted(orgID, group string) (map[string]string, error) {
docs, err := GetSecretGroup(orgID, group)
func GetSecretGroupDecrypted(instanceID, group string) (map[string]string, error) {
docs, err := GetSecretGroup(instanceID, group)
if err != nil {
return nil, err
}
@@ -112,12 +112,12 @@ func GetSecretGroupDecrypted(orgID, group string) (map[string]string, error) {
return result, nil
}
func RevealSecret(orgID, group, key string) (string, error) {
func RevealSecret(instanceID, 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{"org_id": orgID, "group": group, "key": key}).Decode(&doc)
err := db.Col("secrets").FindOne(ctx, bson.M{"instance_id": instanceID, "group": group, "key": key}).Decode(&doc)
if err == mongo.ErrNoDocuments {
return "", fmt.Errorf("secret not found")
}
@@ -127,7 +127,7 @@ func RevealSecret(orgID, group, key string) (string, error) {
return decryptString(doc.EncryptedValue)
}
func UpsertSecrets(orgID, group string, values map[string]string) error {
func UpsertSecrets(instanceID, group string, values map[string]string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -137,9 +137,9 @@ func UpsertSecrets(orgID, group string, values map[string]string) error {
return fmt.Errorf("encrypt %s: %w", key, err)
}
_, err = db.Col("secrets").UpdateOne(ctx,
bson.M{"org_id": orgID, "group": group, "key": key},
bson.M{"instance_id": instanceID, "group": group, "key": key},
bson.M{"$set": bson.M{
"org_id": orgID,
"instance_id": instanceID,
"encrypted_value": encrypted,
"updated_at": time.Now(),
}},
@@ -152,7 +152,6 @@ func UpsertSecrets(orgID, group string, values map[string]string) error {
return nil
}
func SortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
@@ -162,20 +161,18 @@ func SortedKeys(m map[string]string) []string {
return keys
}
func DeleteSecret(orgID, group, key string) error {
func DeleteSecret(instanceID, group, key string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("secrets").DeleteOne(ctx, bson.M{"org_id": orgID, "group": group, "key": key})
_, err := db.Col("secrets").DeleteOne(ctx, bson.M{"instance_id": instanceID, "group": group, "key": key})
return err
}
func DeleteSecretGroup(orgID, group string) error {
func DeleteSecretGroup(instanceID, group string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := db.Col("secrets").DeleteMany(ctx, bson.M{"org_id": orgID, "group": group})
_, err := db.Col("secrets").DeleteMany(ctx, bson.M{"instance_id": instanceID, "group": group})
return err
}
+21 -47
View File
@@ -30,14 +30,14 @@ func HashToken(token string) string {
return hex.EncodeToString(sum[:])
}
func CreateServer(orgID string) (*models.Server, string, error) {
func CreateServer(instanceID 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,
InstanceID: instanceID,
ServerID: uuid.NewString(),
PreRegToken: token,
PreRegExpires: &expires,
@@ -54,21 +54,18 @@ func CreateServer(orgID string) (*models.Server, string, error) {
return s, token, nil
}
func GetServer(orgID, serverID string) (*models.Server, error) {
func GetServer(instanceID, 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)
err := db.Col("servers").FindOne(ctx, bson.M{"server_id": serverID, "instance_id": instanceID}).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()
@@ -96,9 +93,6 @@ func GetServerByPreRegToken(token string) (*models.Server, error) {
return &s, nil
}
func OSTypeFromInfo(osInfo string) string {
if strings.HasPrefix(strings.ToLower(osInfo), "windows") {
return "windows"
@@ -106,8 +100,6 @@ func OSTypeFromInfo(osInfo string) string {
return "linux"
}
func defaultConsoleFields(osType string) (protocols []string, sshPort, rdpPort int) {
if osType == "windows" {
return []string{"rdp"}, 22, 3389
@@ -181,19 +173,13 @@ func ValidateAgentToken(serverID, agentToken string) (*models.Server, error) {
if err != nil {
return nil, fmt.Errorf("invalid agent token")
}
if s.OrgID == "" {
if s.InstanceID == "" {
return nil, fmt.Errorf("server %s has no org", serverID)
}
return &s, nil
}
func BackfillConsoleConfig(srv *models.Server) error {
if srv == nil || len(srv.ConsoleProtocols) > 0 {
return nil
@@ -236,12 +222,12 @@ func UpdateServerLastSeen(serverID, agentVersion string) error {
return err
}
func ListServers(orgID string) ([]models.Server, error) {
func ListServers(instanceID 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{"org_id": orgID}, opts)
cursor, err := db.Col("servers").Find(ctx, bson.M{"instance_id": instanceID}, opts)
if err != nil {
return nil, err
}
@@ -254,16 +240,16 @@ func ListServers(orgID string) ([]models.Server, error) {
return servers, nil
}
func DeleteServer(orgID, serverID string) error {
func DeleteServer(instanceID, 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, "org_id": orgID})
_, err := db.Col("servers").DeleteOne(ctx, bson.M{"server_id": serverID, "instance_id": instanceID})
if err != nil {
return err
}
_, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID, "org_id": orgID})
_, err = db.Col("assignments").DeleteMany(ctx, bson.M{"server_id": serverID, "instance_id": instanceID})
return err
}
@@ -283,42 +269,31 @@ func StoreAvailableUpdates(serverID string, pkgs []models.PackageUpdate) error {
}
func MarkOfflineServers() error {
orgIDs, err := ListOrgIDs()
instanceIDs, err := ListInstanceIDs()
if 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)
for _, instanceID := range instanceIDs {
if err := markOfflineForFilter(bson.M{"instance_id": instanceID}, instanceID); err != nil {
log.Printf("offline sweep failed for org %s: %v", instanceID, err)
}
}
if err := markOfflineForFilter(bson.M{"org_id": bson.M{"$nin": orgIDs}}, ""); err != nil {
if err := markOfflineForFilter(bson.M{"instance_id": bson.M{"$nin": instanceIDs}}, ""); err != nil {
log.Printf("offline sweep failed for orphaned servers: %v", err)
}
return nil
}
func markOfflineForFilter(scope bson.M, orgID string) error {
func markOfflineForFilter(scope bson.M, instanceID 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 instanceID != "" {
settings, _ = GetSettings(instanceID)
if settings != nil && settings.Alerts.OfflineThresholdMinutes > 0 {
thresholdMinutes = settings.Alerts.OfflineThresholdMinutes
}
@@ -333,7 +308,6 @@ func markOfflineForFilter(scope bson.M, orgID string) error {
filter[k] = v
}
cursor, err := db.Col("servers").Find(ctx, filter)
if err != nil {
return err
@@ -349,7 +323,7 @@ func markOfflineForFilter(scope bson.M, orgID string) error {
}
for _, s := range goingOffline {
LogEvent(s.OrgID, "server.offline", "system", s.ServerID, "", fmt.Sprintf("%s (%s) went offline", s.Hostname, s.IPAddress))
LogEvent(s.InstanceID, "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)
}
+14 -30
View File
@@ -34,9 +34,6 @@ var defaultSettings = models.Settings{
},
}
func EnsureSettingsIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -46,16 +43,12 @@ func EnsureSettingsIndexes() error {
}
if _, err := db.Col("settings").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "org_id", Value: 1}},
Keys: bson.D{{Key: "instance_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").
@@ -66,15 +59,15 @@ func EnsureSettingsIndexes() error {
return err
}
func GetSettings(orgID string) (*models.Settings, error) {
func GetSettings(instanceID 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{"org_id": orgID}).Decode(&s)
err := db.Col("settings").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&s)
if err == mongo.ErrNoDocuments {
cp := defaultSettings
cp.OrgID = orgID
cp.InstanceID = instanceID
return &cp, nil
}
if err != nil {
@@ -89,9 +82,7 @@ func hashToken(token string) string {
return hex.EncodeToString(sum[:])
}
func RotateSecretsReadToken(orgID string) (string, error) {
func RotateSecretsReadToken(instanceID string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -102,13 +93,13 @@ func RotateSecretsReadToken(orgID string) (string, error) {
token := hex.EncodeToString(raw)
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{"org_id": orgID},
bson.M{"instance_id": instanceID},
bson.M{
"$set": bson.M{
"secrets.read_token_hash": hashToken(token),
"secrets.rotated_at": time.Now(),
},
"$setOnInsert": bson.M{"org_id": orgID},
"$setOnInsert": bson.M{"instance_id": instanceID},
},
options.UpdateOne().SetUpsert(true),
)
@@ -118,9 +109,6 @@ func RotateSecretsReadToken(orgID string) (string, error) {
return token, nil
}
func ResolveSecretsReadToken(token string) (string, bool) {
if token == "" {
return "", false
@@ -130,7 +118,7 @@ func ResolveSecretsReadToken(token string) (string, bool) {
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 == "" {
if err != nil || s.Secrets.ReadTokenHash == "" || s.InstanceID == "" {
return "", false
}
expected, err := hex.DecodeString(s.Secrets.ReadTokenHash)
@@ -141,10 +129,10 @@ func ResolveSecretsReadToken(token string) (string, bool) {
if subtle.ConstantTimeCompare(expected, got[:]) != 1 {
return "", false
}
return s.OrgID, true
return s.InstanceID, true
}
func SaveSettings(orgID string, alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error {
func SaveSettings(instanceID string, alerts models.AlertSettings, email models.EmailSettings, retentionDays *int) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -160,17 +148,15 @@ func SaveSettings(orgID string, alerts models.AlertSettings, email models.EmailS
set["workflow_log_retention_days"] = *retentionDays
}
_, err := db.Col("settings").UpdateOne(ctx,
bson.M{"org_id": orgID},
bson.M{"$set": set, "$setOnInsert": bson.M{"org_id": orgID}},
bson.M{"instance_id": instanceID},
bson.M{"$set": set, "$setOnInsert": bson.M{"instance_id": instanceID}},
options.UpdateOne().SetUpsert(true),
)
return err
}
func GetWorkflowLogRetentionDays(orgID string) (int, error) {
s, err := GetSettings(orgID)
func GetWorkflowLogRetentionDays(instanceID string) (int, error) {
s, err := GetSettings(instanceID)
if err != nil {
return 30, err
}
@@ -241,7 +227,6 @@ func SendOfflineEmail(cfg models.EmailSettings, hostname, serverID, ipAddress st
}
}
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 {
@@ -278,4 +263,3 @@ func sendMailTLS(addr, host string, auth smtp.Auth, from string, to []string, ms
}
return c.Quit()
}
+4 -10
View File
@@ -9,7 +9,6 @@ import (
const StepDocKind = "vantage.step/v1"
type StepDoc struct {
Kind string `json:"kind"`
Name string `json:"name"`
@@ -21,7 +20,6 @@ type StepDoc struct {
SecretRefs []string `json:"secret_refs"`
}
func ExportStepDoc(s models.WorkflowStep) StepDoc {
return StepDoc{
Kind: StepDocKind,
@@ -35,8 +33,6 @@ func ExportStepDoc(s models.WorkflowStep) StepDoc {
}
}
func ParseStepDoc(b []byte) (models.WorkflowStep, error) {
var d StepDoc
if err := json.Unmarshal(b, &d); err != nil {
@@ -65,20 +61,18 @@ func ParseStepDoc(b []byte) (models.WorkflowStep, error) {
}, nil
}
func ImportStepToLibrary(orgID string, b []byte) (*models.WorkflowStep, error) {
func ImportStepToLibrary(instanceID string, b []byte) (*models.WorkflowStep, error) {
s, err := ParseStepDoc(b)
if err != nil {
return nil, err
}
return CreateStep(orgID, s)
return CreateStep(instanceID, s)
}
func ExportStep(orgID, stepID string) ([]byte, error) {
func ExportStep(instanceID, stepID string) ([]byte, error) {
ctx, cancel := wfCtx()
defer cancel()
s, err := getStep(ctx, orgID, stepID)
s, err := getStep(ctx, instanceID, stepID)
if err != nil {
return nil, err
}
+13 -40
View File
@@ -14,7 +14,6 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo"
)
func WorkflowLogDir() string {
dir := os.Getenv("VANTAGE_WORKFLOW_LOG_DIR")
if dir == "" {
@@ -24,19 +23,14 @@ func WorkflowLogDir() string {
return dir
}
func ServerRunLogPath(runID, serverID string) string {
return filepath.Join(WorkflowLogDir(), runID, serverID+".log")
}
func logTS() string {
return time.Now().UTC().Format("2006-01-02T15:04:05.000") + "Z"
}
func AppendMarker(runID, serverID, text string) (int64, error) {
path := ServerRunLogPath(runID, serverID)
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
@@ -47,19 +41,17 @@ func AppendMarker(runID, serverID, text string) (int64, error) {
return 0, err
}
defer f.Close()
off, _ := f.Seek(0, 2)
off, _ := f.Seek(0, 2)
if _, err := f.WriteString("[" + logTS() + "] " + text + "\n"); err != nil {
return off, err
}
return off, nil
}
type stepLogWriter struct {
mu sync.Mutex
f *os.File
carry []byte
carry []byte
secrets []string
}
@@ -70,7 +62,6 @@ type stepLogRegistry struct {
var StepLogs = &stepLogRegistry{writers: make(map[string]*stepLogWriter)}
func (r *stepLogRegistry) Open(commandID, path string, secrets []string) error {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
@@ -92,10 +83,6 @@ func (r *stepLogRegistry) get(commandID string) *stepLogWriter {
return r.writers[commandID]
}
func (r *stepLogRegistry) Append(commandID string, data []byte) {
w := r.get(commandID)
if w == nil {
@@ -115,7 +102,6 @@ func (r *stepLogRegistry) Append(commandID string, data []byte) {
w.carry = append([]byte{}, buf...)
}
func (w *stepLogWriter) writeLine(line []byte) {
masked := maskBytes(line, w.secrets)
_, _ = w.f.WriteString("[" + logTS() + "] ")
@@ -123,7 +109,6 @@ func (w *stepLogWriter) writeLine(line []byte) {
_, _ = w.f.WriteString("\n")
}
func (r *stepLogRegistry) Close(commandID string) {
r.mu.Lock()
w := r.writers[commandID]
@@ -152,9 +137,6 @@ func maskBytes(b []byte, secrets []string) []byte {
return []byte(s)
}
func StartLogSweeper() {
go func() {
sweepLogs()
@@ -166,10 +148,6 @@ func StartLogSweeper() {
}()
}
func sweepLogs() {
base := WorkflowLogDir()
entries, err := os.ReadDir(base)
@@ -186,30 +164,28 @@ func sweepLogs() {
runID := e.Name()
dir := filepath.Join(base, runID)
orgID, finishedAt, found, err := runRetentionInfo(runID)
instanceID, 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
continue
}
days, ok := cache[orgID]
days, ok := cache[instanceID]
if !ok {
days = defaultRetentionDays
if orgID != "" {
if v, err := GetWorkflowLogRetentionDays(orgID); err == nil {
if instanceID != "" {
if v, err := GetWorkflowLogRetentionDays(instanceID); err == nil {
days = v
}
}
cache[orgID] = days
cache[instanceID] = days
}
if days <= 0 {
continue
continue
}
cutoff := now.AddDate(0, 0, -days)
@@ -219,7 +195,7 @@ func sweepLogs() {
}
continue
}
if fi, e := os.Stat(dir); e == nil && fi.ModTime().Before(cutoff) {
_ = os.RemoveAll(dir)
}
@@ -228,14 +204,11 @@ func sweepLogs() {
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"`
InstanceID string `bson:"instance_id"`
FinishedAt *time.Time `bson:"finished_at"`
}
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID}).Decode(&run)
@@ -245,5 +218,5 @@ func runRetentionInfo(runID string) (string, *time.Time, bool, error) {
if err != nil {
return "", nil, false, err
}
return run.OrgID, run.FinishedAt, true, nil
return run.InstanceID, run.FinishedAt, true, nil
}
-6
View File
@@ -11,12 +11,8 @@ type stepResultRegistry struct {
pending map[string]chan *pb.StepResult
}
var StepResults = &stepResultRegistry{pending: make(map[string]chan *pb.StepResult)}
func (r *stepResultRegistry) Await(commandID string) <-chan *pb.StepResult {
ch := make(chan *pb.StepResult, 1)
r.mu.Lock()
@@ -25,14 +21,12 @@ func (r *stepResultRegistry) Await(commandID string) <-chan *pb.StepResult {
return ch
}
func (r *stepResultRegistry) Cancel(commandID string) {
r.mu.Lock()
delete(r.pending, commandID)
r.mu.Unlock()
}
func (r *stepResultRegistry) Deliver(res *pb.StepResult) {
if res == nil {
return
+3 -13
View File
@@ -5,12 +5,8 @@ import (
"strings"
)
var keyAssign = regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)=`)
func DeriveOutputs(script string) []string {
out := []string{}
seen := map[string]bool{}
@@ -20,7 +16,7 @@ func DeriveOutputs(script string) []string {
}
for _, m := range keyAssign.FindAllStringSubmatch(line, -1) {
key := m[1]
if key == "WORKFLOW_ENV" || key == "env" {
continue
}
@@ -34,11 +30,5 @@ func DeriveOutputs(script string) []string {
return out
}
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
func Slugify(name string) string {
s := strings.ToLower(name)
s = slugStrip.ReplaceAllString(s, "-")
return strings.Trim(s, "-")
}
// Slugify lived here and was mirrored by hand in sitesvc. It now has a single
// definition in shared/provision, which both services import.
+4 -6
View File
@@ -13,17 +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,
"instance_id": srv.InstanceID,
"server_id": serverID,
"revoked_at": nil,
})
if err != nil {
return nil, err
@@ -38,7 +36,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, "org_id": srv.OrgID}).Decode(&key)
err := db.Col("keys").FindOne(ctx, bson.M{"key_id": a.KeyID, "instance_id": srv.InstanceID}).Decode(&key)
if err != nil {
continue
}
+28 -57
View File
@@ -7,88 +7,59 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/server/internal/models"
"github.com/mrhid6/vantage/shared/provision"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"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) {
func CountInstanceUsers(instanceID 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})
return db.Col("users").CountDocuments(ctx, bson.M{"instance_id": instanceID})
}
func countOtherOwners(orgID, exceptUserID string) (int64, error) {
func countOtherOwners(instanceID, 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},
"instance_id": instanceID,
"role": models.RoleOwner,
"user_id": bson.M{"$ne": exceptUserID},
})
}
func GetUserInOrg(orgID, userID string) (*models.User, error) {
func GetUserInInstance(instanceID, 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)
err := db.Col("users").FindOne(ctx, bson.M{"user_id": userID, "instance_id": instanceID}).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)
}
func CreateUser(instanceID, email, password, role, authSource string) (*models.User, error) {
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
u, err := provision.CreateUser(ctx, db.Database, instanceID, email, password, role, authSource)
if errors.Is(err, provision.ErrEmailTaken) {
// Preserve the exact error string the API returned before this call
// was delegated to the shared module.
return nil, fmt.Errorf("email already registered")
}
return u, nil
return u, err
}
func GetUserByEmail(email string) (*models.User, error) {
@@ -119,10 +90,10 @@ func TouchLastLogin(userID string) error {
return err
}
func ListUsers(orgID string) ([]models.User, error) {
func ListUsers(instanceID 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})
cursor, err := db.Col("users").Find(ctx, bson.M{"instance_id": instanceID})
if err != nil {
return nil, err
}
@@ -134,17 +105,17 @@ func ListUsers(orgID string) ([]models.User, error) {
return users, nil
}
func UpdateUserRole(orgID, userID, role string) error {
func UpdateUserRole(instanceID, userID, role string) error {
if !models.ValidRole(role) {
return fmt.Errorf("invalid role %q", role)
}
target, err := GetUserInOrg(orgID, userID)
target, err := GetUserInInstance(instanceID, userID)
if err != nil {
return fmt.Errorf("user not found")
}
if target.Role == models.RoleOwner && role != models.RoleOwner {
others, err := countOtherOwners(orgID, userID)
others, err := countOtherOwners(instanceID, userID)
if err != nil {
return err
}
@@ -156,18 +127,18 @@ func UpdateUserRole(orgID, userID, role string) error {
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{"user_id": userID, "instance_id": instanceID},
bson.M{"$set": bson.M{"role": role}})
return err
}
func DeleteUser(orgID, userID string) error {
target, err := GetUserInOrg(orgID, userID)
func DeleteUser(instanceID, userID string) error {
target, err := GetUserInInstance(instanceID, userID)
if err != nil {
return fmt.Errorf("user not found")
}
if target.Role == models.RoleOwner {
others, err := countOtherOwners(orgID, userID)
others, err := countOtherOwners(instanceID, userID)
if err != nil {
return err
}
@@ -178,6 +149,6 @@ func DeleteUser(orgID, userID string) error {
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})
_, err = db.Col("users").DeleteOne(ctx, bson.M{"user_id": userID, "instance_id": instanceID})
return err
}
-1
View File
@@ -6,7 +6,6 @@ import (
"github.com/mrhid6/vantage/server/internal/models"
)
func ValidateWorkflow(w models.Workflow) error {
for i, ref := range w.Steps {
hasLib := ref.StepID != ""
+19 -19
View File
@@ -17,8 +17,8 @@ import (
const stepDispatchGrace = 15 * time.Second
func TriggerWorkflow(orgID, workflowID, actor string) (string, error) {
wf, err := GetWorkflow(orgID, workflowID)
func TriggerWorkflow(instanceID, workflowID, actor string) (string, error) {
wf, err := GetWorkflow(instanceID, workflowID)
if err != nil {
return "", err
}
@@ -29,24 +29,24 @@ func TriggerWorkflow(orgID, workflowID, actor string) (string, error) {
return "", fmt.Errorf("workflow has no steps")
}
if err := validateTargetServers(orgID, wf.TargetServerIDs); err != nil {
if err := validateTargetServers(instanceID, wf.TargetServerIDs); err != nil {
return "", err
}
ctx, cancel := wfCtx()
running := db.Col("workflow_runs").FindOne(ctx, bson.M{"org_id": orgID, "workflow_id": workflowID, "status": "running"})
running := db.Col("workflow_runs").FindOne(ctx, bson.M{"instance_id": instanceID, "workflow_id": workflowID, "status": "running"})
cancel()
if running.Err() == nil {
return "", fmt.Errorf("workflow already has a run in progress")
}
resolved, err := resolveSteps(orgID, wf)
resolved, err := resolveSteps(instanceID, wf)
if err != nil {
return "", err
}
run := models.WorkflowRun{
OrgID: orgID,
InstanceID: instanceID,
RunID: uuid.New().String(),
WorkflowID: workflowID,
Name: wf.Name,
@@ -78,7 +78,7 @@ func TriggerWorkflow(orgID, workflowID, actor string) (string, error) {
return run.RunID, nil
}
func resolveSteps(orgID string, wf *models.Workflow) ([]models.ResolvedStep, error) {
func resolveSteps(instanceID 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(orgID string, wf *models.Workflow) ([]models.ResolvedStep, err
out = append(out, resolveInlineStep(ref))
continue
}
lib, err := getStep(ctx, orgID, ref.StepID)
lib, err := getStep(ctx, instanceID, ref.StepID)
if err != nil {
return nil, err
}
@@ -163,7 +163,7 @@ func executeRun(runID string) {
done := make(chan int, len(run.ServerRuns))
for i := range run.ServerRuns {
go func(idx int) {
runServer(run.OrgID, runID, idx, run.Steps, run.ServerRuns[idx].ServerID)
runServer(run.InstanceID, runID, idx, run.Steps, run.ServerRuns[idx].ServerID)
done <- idx
}(i)
}
@@ -185,7 +185,7 @@ func executeRun(runID string) {
bson.M{"$set": bson.M{"status": status, "finished_at": now}})
}
func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, serverID string) {
func runServer(instanceID, 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})
@@ -212,7 +212,7 @@ func runServer(orgID, runID string, srvIdx int, steps []models.ResolvedStep, ser
maxAttempts = step.MaxRetries + 1
}
secretVals := resolveSecrets(orgID, step.SecretRefs)
secretVals := resolveSecrets(instanceID, step.SecretRefs)
for k, v := range secretVals {
allSecrets[k] = v
}
@@ -347,7 +347,7 @@ func expandVars(v string, lookup map[string]string) string {
})
}
func resolveSecrets(orgID string, refs []string) map[string]string {
func resolveSecrets(instanceID string, refs []string) map[string]string {
out := map[string]string{}
for _, ref := range refs {
@@ -355,7 +355,7 @@ func resolveSecrets(orgID string, refs []string) map[string]string {
if len(parts) != 2 {
continue
}
if v, err := RevealSecret(orgID, parts[0], parts[1]); err == nil {
if v, err := RevealSecret(instanceID, parts[0], parts[1]); err == nil {
out[parts[1]] = v
}
}
@@ -453,21 +453,21 @@ func getRunByID(runID string) (*models.WorkflowRun, error) {
return &r, err
}
func GetRun(orgID, runID string) (*models.WorkflowRun, error) {
func GetRun(instanceID, runID string) (*models.WorkflowRun, error) {
ctx, cancel := wfCtx()
defer cancel()
var r models.WorkflowRun
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID, "org_id": orgID}).Decode(&r)
err := db.Col("workflow_runs").FindOne(ctx, bson.M{"run_id": runID, "instance_id": instanceID}).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) {
func ListRuns(instanceID, 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},
cur, err := db.Col("workflow_runs").Find(ctx, bson.M{"instance_id": instanceID, "workflow_id": workflowID},
options.Find().SetSort(bson.D{{Key: "started_at", Value: -1}}).SetLimit(limit))
if err != nil {
return nil, err
@@ -480,12 +480,12 @@ func ListRuns(orgID, workflowID string, limit int64) ([]models.WorkflowRun, erro
return runs, nil
}
func CancelRun(orgID, runID string) error {
func CancelRun(instanceID, runID string) error {
now := time.Now()
ctx, cancel := wfCtx()
defer cancel()
_, err := db.Col("workflow_runs").UpdateOne(ctx,
bson.M{"org_id": orgID, "run_id": runID, "status": "running"},
bson.M{"instance_id": instanceID, "run_id": runID, "status": "running"},
bson.M{"$set": bson.M{"status": "cancelled", "finished_at": now}})
return err
}
+30 -42
View File
@@ -25,13 +25,12 @@ 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: "org_id", Value: 1}, {Key: "slug", Value: 1}},
Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "slug", Value: 1}},
Options: options.Index().SetUnique(true).
SetPartialFilterExpression(bson.M{"source": "default"}),
}); err != nil {
@@ -48,12 +47,10 @@ func EnsureWorkflowIndexes() error {
return err
}
func ListSteps(orgID string) ([]models.WorkflowStep, error) {
func ListSteps(instanceID string) ([]models.WorkflowStep, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflow_steps").Find(ctx, bson.M{"org_id": orgID},
cur, err := db.Col("workflow_steps").Find(ctx, bson.M{"instance_id": instanceID},
options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
@@ -66,12 +63,10 @@ func ListSteps(orgID string) ([]models.WorkflowStep, error) {
return steps, nil
}
func StepUsageCounts(orgID string) (map[string]int, error) {
func StepUsageCounts(instanceID string) (map[string]int, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflows").Find(ctx, bson.M{"org_id": orgID})
cur, err := db.Col("workflows").Find(ctx, bson.M{"instance_id": instanceID})
if err != nil {
return nil, err
}
@@ -94,10 +89,10 @@ func StepUsageCounts(orgID string) (map[string]int, error) {
return counts, nil
}
func CreateStep(orgID string, s models.WorkflowStep) (*models.WorkflowStep, error) {
func CreateStep(instanceID string, s models.WorkflowStep) (*models.WorkflowStep, error) {
ctx, cancel := wfCtx()
defer cancel()
s.OrgID = orgID
s.InstanceID = instanceID
s.StepID = uuid.New().String()
s.CreatedAt = time.Now()
s.UpdatedAt = s.CreatedAt
@@ -117,10 +112,10 @@ func CreateStep(orgID string, s models.WorkflowStep) (*models.WorkflowStep, erro
return &s, nil
}
func UpdateStep(orgID, stepID string, s models.WorkflowStep) error {
func UpdateStep(instanceID, stepID string, s models.WorkflowStep) error {
ctx, cancel := wfCtx()
defer cancel()
_, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}, bson.M{"$set": bson.M{
_, err := db.Col("workflow_steps").UpdateOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}, bson.M{"$set": bson.M{
"name": s.Name,
"description": s.Description,
"interpreter": s.Interpreter,
@@ -133,14 +128,14 @@ func UpdateStep(orgID, stepID string, s models.WorkflowStep) error {
return err
}
func DeleteStep(orgID, stepID string) error {
func DeleteStep(instanceID, stepID string) error {
ctx, cancel := wfCtx()
defer cancel()
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}); err != nil {
if _, err := db.Col("workflow_steps").DeleteOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}); err != nil {
return err
}
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID, "org_id": orgID})
cur, err := db.Col("workflows").Find(ctx, bson.M{"steps.step_id": stepID, "instance_id": instanceID})
if err != nil {
return err
}
@@ -170,21 +165,19 @@ func DeleteStep(orgID, stepID string) error {
return nil
}
func getStep(ctx context.Context, orgID, stepID string) (*models.WorkflowStep, error) {
func getStep(ctx context.Context, instanceID, stepID string) (*models.WorkflowStep, error) {
var s models.WorkflowStep
err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID, "org_id": orgID}).Decode(&s)
err := db.Col("workflow_steps").FindOne(ctx, bson.M{"step_id": stepID, "instance_id": instanceID}).Decode(&s)
if err == mongo.ErrNoDocuments {
return nil, fmt.Errorf("step %s not found", stepID)
}
return &s, err
}
func ListWorkflows(orgID string) ([]models.Workflow, error) {
func ListWorkflows(instanceID string) ([]models.Workflow, error) {
ctx, cancel := wfCtx()
defer cancel()
cur, err := db.Col("workflows").Find(ctx, bson.M{"org_id": orgID},
cur, err := db.Col("workflows").Find(ctx, bson.M{"instance_id": instanceID},
options.Find().SetSort(bson.D{{Key: "name", Value: 1}}))
if err != nil {
return nil, err
@@ -197,21 +190,21 @@ func ListWorkflows(orgID string) ([]models.Workflow, error) {
return wfs, nil
}
func GetWorkflow(orgID, id string) (*models.Workflow, error) {
func GetWorkflow(instanceID, 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, "org_id": orgID}).Decode(&w)
err := db.Col("workflows").FindOne(ctx, bson.M{"workflow_id": id, "instance_id": instanceID}).Decode(&w)
if err == mongo.ErrNoDocuments {
return nil, fmt.Errorf("workflow not found")
}
return &w, err
}
func CreateWorkflow(orgID string, w models.Workflow) (*models.Workflow, error) {
func CreateWorkflow(instanceID string, w models.Workflow) (*models.Workflow, error) {
ctx, cancel := wfCtx()
defer cancel()
w.OrgID = orgID
w.InstanceID = instanceID
w.WorkflowID = uuid.New().String()
w.CreatedAt = time.Now()
w.UpdatedAt = w.CreatedAt
@@ -224,7 +217,7 @@ func CreateWorkflow(orgID string, w models.Workflow) (*models.Workflow, error) {
if err := ValidateWorkflow(w); err != nil {
return nil, err
}
if err := validateTargetServers(orgID, w.TargetServerIDs); err != nil {
if err := validateTargetServers(instanceID, w.TargetServerIDs); err != nil {
return nil, err
}
normalizeInlineSteps(&w)
@@ -234,17 +227,17 @@ func CreateWorkflow(orgID string, w models.Workflow) (*models.Workflow, error) {
return &w, nil
}
func UpdateWorkflow(orgID, id string, w models.Workflow) error {
func UpdateWorkflow(instanceID, 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 {
if err := validateTargetServers(instanceID, w.TargetServerIDs); err != nil {
return err
}
normalizeInlineSteps(&w)
_, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id, "org_id": orgID}, bson.M{"$set": bson.M{
_, err := db.Col("workflows").UpdateOne(ctx, bson.M{"workflow_id": id, "instance_id": instanceID}, bson.M{"$set": bson.M{
"name": w.Name,
"target_server_ids": w.TargetServerIDs,
"steps": w.Steps,
@@ -253,20 +246,15 @@ func UpdateWorkflow(orgID, id string, w models.Workflow) error {
return err
}
func validateTargetServers(orgID string, serverIDs []string) error {
func validateTargetServers(instanceID string, serverIDs []string) error {
for _, sid := range serverIDs {
if _, err := GetServer(orgID, sid); err != nil {
if _, err := GetServer(instanceID, 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
@@ -288,9 +276,9 @@ func normalizeInlineSteps(w *models.Workflow) {
}
}
func DeleteWorkflow(orgID, id string) error {
func DeleteWorkflow(instanceID, id string) error {
ctx, cancel := wfCtx()
defer cancel()
_, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id, "org_id": orgID})
_, err := db.Col("workflows").DeleteOne(ctx, bson.M{"workflow_id": id, "instance_id": instanceID})
return err
}
+19
View File
@@ -0,0 +1,19 @@
module github.com/mrhid6/vantage/shared
go 1.26.4
require (
github.com/google/uuid v1.6.0
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
)
require (
github.com/klauspost/compress v1.17.6 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.2.0 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/text v0.40.0 // indirect
)
+48
View File
@@ -0,0 +1,48 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+39
View File
@@ -0,0 +1,39 @@
// Package indexes declares the MongoDB indexes more than one Vantage service
// depends on.
package indexes
import (
"context"
"fmt"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// EnsureCoreIndexes declares the unique indexes on users.email and orgs.slug.
//
// These are a security property, not an optimisation. GetUserByEmail does an
// unscoped FindOne, so a duplicate email would let the OIDC cross-org guard
// compare against an arbitrary user. Every caller must treat a failure here as
// fatal.
//
// Creating an index that already exists with the same specification is a no-op,
// so this is safe to call at every boot from every service.
func EnsureCoreIndexes(ctx context.Context, db *mongo.Database) error {
if _, err := db.Collection("users").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "email", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return fmt.Errorf("users.email index: %w", err)
}
if _, err := db.Collection("instances").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "slug", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return fmt.Errorf("instances.slug index: %w", err)
}
return nil
}
+23
View File
@@ -0,0 +1,23 @@
// Package models holds the MongoDB documents written by more than one Vantage
// service. Documents only the control plane touches stay in
// server/internal/models.
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// Instance is one deployment of Vantage: its own subdomain, users, servers,
// keys, workflows, monitors and secrets. It is the unit a licence attaches to.
//
// A paying customer may hold several. That grouping is called an Account and
// lives only in the admin control plane — this service never sees it.
type Instance struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
Name string `bson:"name" json:"name"`
Slug string `bson:"slug" json:"slug"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
+40
View File
@@ -0,0 +1,40 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AlertSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
WebhookURL string `bson:"webhook_url" json:"webhook_url"`
OfflineThresholdMinutes int `bson:"offline_threshold_minutes" json:"offline_threshold_minutes"`
}
type EmailSettings struct {
Enabled bool `bson:"enabled" json:"enabled"`
SMTPHost string `bson:"smtp_host" json:"smtp_host"`
SMTPPort int `bson:"smtp_port" json:"smtp_port"`
Username string `bson:"username" json:"username"`
Password string `bson:"password" json:"password"`
FromAddr string `bson:"from_addr" json:"from_addr"`
ToAddrs []string `bson:"to_addrs" json:"to_addrs"`
UseTLS bool `bson:"use_tls" json:"use_tls"`
}
type SecretsSettings struct {
ReadTokenHash string `bson:"read_token_hash,omitempty" json:"-"`
ReadTokenSet bool `bson:"-" json:"read_token_set"`
RotatedAt time.Time `bson:"rotated_at,omitempty" json:"rotated_at,omitempty"`
}
type Settings struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"-"`
InstanceID string `bson:"instance_id" json:"instance_id"`
Alerts AlertSettings `bson:"alerts" json:"alerts"`
Email EmailSettings `bson:"email" json:"email"`
Secrets SecretsSettings `bson:"secrets" json:"secrets"`
WorkflowLogRetentionDays *int `bson:"workflow_log_retention_days,omitempty" json:"workflow_log_retention_days,omitempty"`
}
+33
View File
@@ -0,0 +1,33 @@
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"`
InstanceID string `bson:"instance_id" json:"instance_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"`
}
+74
View File
@@ -0,0 +1,74 @@
package provision
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// ErrNameRejected wraps every reason a name cannot become an instance.
var ErrNameRejected = errors.New("organisation name rejected")
const maxSlugAttempts = 50
// CreateInstance inserts an instance under the first free slug derived from name.
//
// The count-then-insert loop is racy on its own. It is safe only because
// instances.slug carries a unique index: a lost race surfaces as a duplicate-key
// error, which we treat as "that slug is taken" and retry. Do not remove the
// duplicate-key branch, and do not remove the index.
func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*models.Instance, error) {
base, err := BaseSlug(name)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
}
for attempt := 1; attempt <= maxSlugAttempts; attempt++ {
slug := NextSlug(base, attempt)
n, err := db.Collection("instances").CountDocuments(ctx, bson.M{"slug": slug})
if err != nil {
return nil, err
}
if n > 0 {
continue
}
inst := models.Instance{
InstanceID: uuid.NewString(),
Name: name,
Slug: slug,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Collection("instances").InsertOne(ctx, inst); err != nil {
if mongo.IsDuplicateKeyError(err) {
continue // lost the race; try the next slug
}
return nil, err
}
return &inst, nil
}
return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name)
}
// RollbackInstance deletes an instance that has no users.
//
// It refuses an instance that has users. Rollback exists to clean up a
// half-finished signup, and an instance with users is not half-finished.
func RollbackInstance(ctx context.Context, db *mongo.Database, instanceID string) error {
n, err := db.Collection("users").CountDocuments(ctx, bson.M{"instance_id": instanceID})
if err != nil {
return err
}
if n > 0 {
return fmt.Errorf("refusing to roll back instance %s: it has %d user(s)", instanceID, n)
}
_, err = db.Collection("instances").DeleteOne(ctx, bson.M{"instance_id": instanceID})
return err
}
@@ -1,3 +1,10 @@
// Package provision holds the tenant creation rules shared by the control
// plane and sitesvc.
//
// These rules used to be duplicated: the control plane owned one copy and
// sitesvc mirrored it by hand. The copies had already drifted — sitesvc retried
// on a lost slug race while the control plane returned an error. This package
// is the single definition; neither service may reimplement any of it.
package provision
import (
@@ -6,39 +13,28 @@ import (
"strings"
)
/*
Slug rules mirrored from the control plane (server/internal/services: Slugify in
stepscan.go, reservedSlugs and CreateOrg in orgs.go).
They live here rather than being imported because sitesvc is a separate module
with no dependency on the server. That is a deliberate trade: sitesvc stays
small and independent, at the cost of this one duplicated rule set.
Keep the two in step. If the control plane's slug handling, reserved names or
bcrypt cost change, change them here in the same commit nothing enforces the
match automatically, and a divergence would create tenants under rules the app
does not agree with.
*/
const (
MinSlugLength = 3
MaxSlugLength = 40
BcryptCost = 12
)
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
// ReservedSlugs are subdomain labels the platform needs for itself.
var ReservedSlugs = map[string]bool{
"www": true, "api": true, "app": true, "admin": true, "auth": true,
"install": true, "static": true, "_next": true, "default": true,
}
// Slugify lowercases a name and collapses every run of non-alphanumeric
// characters into a single hyphen, trimming hyphens from both ends.
func Slugify(name string) string {
s := strings.ToLower(name)
s = slugStrip.ReplaceAllString(s, "-")
return strings.Trim(s, "-")
}
// BaseSlug turns a name into a validated slug stem, or explains why it cannot.
func BaseSlug(name string) (string, error) {
base := Slugify(name)
if len(base) < MinSlugLength {
@@ -53,6 +49,8 @@ func BaseSlug(name string) (string, error) {
return base, nil
}
// NextSlug returns the candidate slug for a given attempt. Attempt 1 is the
// base itself; later attempts append a counter.
func NextSlug(base string, attempt int) string {
if attempt < 2 {
return base
+65
View File
@@ -0,0 +1,65 @@
package provision
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/mongo"
"golang.org/x/crypto/bcrypt"
)
// BcryptCost is the work factor for every password hash Vantage writes.
// Changing it changes nothing about existing hashes, which carry their own cost.
const BcryptCost = 12
// ErrEmailTaken is returned when the unique index on users.email rejects an insert.
var ErrEmailTaken = errors.New("email already registered")
// CreateUser hashes password and inserts the user. An empty password leaves the
// hash empty, which is how OIDC users are stored.
func CreateUser(ctx context.Context, db *mongo.Database, instanceID, email, password, role, authSource string) (*models.User, error) {
var hash string
if password != "" {
b, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
if err != nil {
return nil, err
}
hash = string(b)
}
return CreateUserWithHash(ctx, db, instanceID, email, hash, role, authSource)
}
// CreateUserWithHash inserts a user whose password was already hashed
// elsewhere. sitesvc hashes at signup and only holds the hash by the time the
// verification link is opened.
func CreateUserWithHash(ctx context.Context, db *mongo.Database, instanceID, email, passwordHash, 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(),
InstanceID: instanceID,
Email: email,
PasswordHash: passwordHash,
Role: role,
AuthSource: authSource,
CreatedAt: time.Now().UTC(),
}
if _, err := db.Collection("users").InsertOne(ctx, u); err != nil {
if mongo.IsDuplicateKeyError(err) {
return nil, ErrEmailTaken
}
return nil, err
}
return u, nil
}
+4 -4
View File
@@ -14,7 +14,7 @@ export default function OverviewPage() {
</p>
<div className="hero__acts">
<Link className="btn btn--solid" href="/start">
Create your organisation
Create your instance
</Link>
<Link className="btn btn--line" href="/platform">
What it does
@@ -133,8 +133,8 @@ export default function OverviewPage() {
<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>
<h3>Create an instance</h3>
<p>You become the owner. Everything inside is invisible to every other instance.</p>
</div>
<div className="flow__c">
<span className="flow__n">THEN</span>
@@ -168,7 +168,7 @@ export default function OverviewPage() {
<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>
<p style={{ color: "var(--ink-2)", marginTop: "0.4rem", fontSize: "0.94rem" }}>Create an instance, install one agent, and watch a key land on a real box.</p>
</div>
<Link className="btn btn--solid" href="/start">
Get started
+4 -4
View File
@@ -82,13 +82,13 @@ export default function PlatformPage() {
<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>
<h2 style={{ fontSize: "var(--s-2)", marginTop: "0.7rem", maxWidth: "22ch" }}>Instances 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
Every server, key, workflow, monitor and secret belongs to an instance, and every lookup is filtered by it. Uniqueness constraints are enforced by the database, not by
application logic.
</p>
</article>
@@ -99,8 +99,8 @@ export default function PlatformPage() {
</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>
<h3>Local or OIDC, per instance</h3>
<p>Sign in with email and password, or connect your own provider. Each instance configures its own issuer and client.</p>
</article>
<article className="cap">
<span className="cap__k">Sessions</span>
+1 -1
View File
@@ -45,7 +45,7 @@ export default function PricingPage() {
<ul>
<li>Up to 3 servers</li>
<li>Keys, workflows and monitors</li>
<li>One member, one organisation</li>
<li>One member, one instance</li>
<li>Community support</li>
</ul>
<Link className="btn btn--line" href="/start">
+6 -6
View File
@@ -1,9 +1,9 @@
import type { Metadata } from "next";
import { OrgForm } from "@/components/OrgForm";
import { InstanceForm } from "@/components/InstanceForm";
export const metadata: Metadata = {
title: "Vantage Cloud",
description: "An organisation owns its servers, keys, workflows, monitors and secrets. Free for three servers, hosted or self-hosted.",
description: "An instance owns its servers, keys, workflows, monitors and secrets. Free for three servers, hosted or self-hosted.",
};
export default function StartPage() {
@@ -12,14 +12,14 @@ export default function StartPage() {
<div className="split">
<div>
<span className="tag">Vantage Cloud</span>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Set up your organisation.</h1>
<h1 style={{ fontSize: "var(--s-3)", margin: "0.8rem 0 1rem", maxWidth: "15ch" }}>Set up your instance.</h1>
<p className="lede" style={{ fontSize: "var(--s-0)" }}>
An organisation owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other organisation. Confirm your email and it is created with you
An instance owns its servers, keys, workflows, monitors and secrets. Nothing inside it is visible to any other instance. Confirm your email and it is created with you
as its owner.
</p>
<div className="card" style={{ marginTop: "1.9rem" }}>
<OrgForm />
<InstanceForm />
</div>
</div>
@@ -30,7 +30,7 @@ export default function StartPage() {
<span className="spec__k">FIRST</span>
<div>
<h3>Confirm your email</h3>
<p>We send a link that works once. Your organisation is created when you open it, not before.</p>
<p>We send a link that works once. Your instance is created when you open it, not before.</p>
</div>
</div>
<div className="spec">
@@ -14,7 +14,7 @@ function slugify(value: string) {
.replace(/^-|-$/g, "");
}
export function OrgForm() {
export function InstanceForm() {
const [slug, setSlug] = useState("");
const [result, setResult] = useState<SubmitResult>({ state: "idle" });
const sending = result.state === "sending";
@@ -25,7 +25,7 @@ export function OrgForm() {
setResult({ state: "sending" });
setResult(
await submitSignup({
org_name: String(data.get("org_name") ?? ""),
instance_name: String(data.get("instance_name") ?? ""),
email: String(data.get("email") ?? ""),
password: String(data.get("password") ?? ""),
website: String(data.get("website") ?? ""),
@@ -38,7 +38,7 @@ export function OrgForm() {
<div role="status">
<h2 style={{ fontSize: "var(--s-1)" }}>Check your email.</h2>
<p style={{ color: "var(--ink-2)", marginTop: "0.5rem" }}>
We sent a confirmation link. Open it and <b>{slug || "your organisation"}</b> is created with you as its owner. The link works once and expires in 24 hours.
We sent a confirmation link. Open it and <b>{slug || "your instance"}</b> is created with you as its owner. The link works once and expires in 24 hours.
</p>
<p style={{ color: "var(--ink-3)", marginTop: "0.75rem", fontSize: "0.88rem" }}>
Nothing exists until you confirm if the email does not arrive, start again or contact support@hostxtra.co.uk.
@@ -54,14 +54,14 @@ export function OrgForm() {
<Honeypot />
<div className="field">
<label htmlFor="o-org">Organisation name</label>
<input id="o-org" name="org_name" type="text" placeholder="Northgate Systems" required onChange={(e) => setSlug(slugify(e.target.value))} aria-describedby="o-org-err" />
<label htmlFor="o-instance">Instance name</label>
<input id="o-instance" name="instance_name" type="text" placeholder="Northgate Systems" required onChange={(e) => setSlug(slugify(e.target.value))} aria-describedby="o-instance-err" />
<span className="hostline">
<b>{slug || "your-org"}</b>.vantage.hostxtra.co.uk
<b>{slug || "your-instance"}</b>.vantage.hostxtra.co.uk
</span>
{fieldError("org_name") && (
<small id="o-org-err" className="field__err">
{fieldError("org_name")}
{fieldError("instance_name") && (
<small id="o-instance-err" className="field__err">
{fieldError("instance_name")}
</small>
)}
</div>
+1 -1
View File
@@ -56,7 +56,7 @@ export async function submitContact(fields: { name: string; email: string; serve
return post(`${SITE_API}/api/contact`, fields);
}
export async function submitSignup(fields: { org_name: string; email: string; password: string; website: string }): Promise<SubmitResult> {
export async function submitSignup(fields: { instance_name: string; email: string; password: string; website: string }): Promise<SubmitResult> {
if (!SITE_API) {
return {
state: "error",
+5 -5
View File
@@ -1,6 +1,6 @@
/
/
import "./.next/dev/types/routes.d.ts";
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+8 -5
View File
@@ -1,13 +1,16 @@
# Context is the repository root; sitesvc depends on the shared module.
FROM golang:1.26-alpine AS builder
WORKDIR /app
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY shared/go.mod shared/go.sum ./shared/
COPY sitesvc/go.mod sitesvc/go.sum ./sitesvc/
RUN cd sitesvc && go mod download
COPY . .
COPY shared/ ./shared/
COPY sitesvc/ ./sitesvc/
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/sitesvc ./cmd
RUN cd sitesvc && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/sitesvc ./cmd
FROM alpine:3.20 AS runner

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