Compare commits

..
20 Commits
Author SHA1 Message Date
mrhid6 eb8b85ccfe fix(server): add the lk go.sum entry the workspace was masking
Server Deploy / deploy (push) Successful in 2m21s
go build inside the Docker image runs outside the workspace, so server/go.sum
needed the hyperboloide/lk entry that GOWORK resolution was supplying locally.
Caught by the image build, not by go build.
2026-07-24 15:35:01 +01:00
mrhid6 09a39090c6 feat(server): grandfather existing cloud instances onto Professional 2026-07-24 15:26:26 +01:00
mrhid6 8626898e5e feat(web): licence banner, settings page and feature gating 2026-07-24 15:25:29 +01:00
mrhid6 ee4dff09c9 feat: show the instance ID after setup 2026-07-24 15:20:00 +01:00
mrhid6 8d88b16f20 feat(server): keep monitors and in-flight runs going when a licence lapses
Monitor execution and the workflow runner are deliberately unguarded: billing
state must not take away a customer's ability to know their infrastructure is
on fire, and killing a run midway leaves a half-configured server.

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

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

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

Spec index updated with plan links and shipped status.
2026-07-24 14:48:45 +01:00
mrhid6 f646ce5c47 chore(server): remove rename-rollback
Migration 0004 is complete and verified on live, so the inverse rename has
served its purpose. Reverting the release now means restoring a backup.
2026-07-24 14:40:35 +01:00
50 changed files with 4180 additions and 151 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -326,7 +326,7 @@ remembering — the same deny-by-default reasoning as spec 2's middleware.
| `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 |
| `LICENSE_SIGNING_KEY` | yes | ECDSA P-384 private key, base32 (lk PrivateKey.ToB32String). **Boot fails without it** — a licensing service that cannot sign is worse than one that is down, because it looks healthy |
| `PUBLIC_URL` | yes | for verification and licence links |
| `SMTP_*` | yes | licence delivery |
| `ADMIN_ORIGIN` | yes | CORS allow-list |
@@ -52,7 +52,7 @@ shared/license/
└── license_test.go
```
Uses `github.com/hyperboloide/lk` (ed25519, base32 encoding).
Uses `github.com/hyperboloide/lk` (ECDSA P-384 with SHA-256, base32 encoding).
### Payload
@@ -197,7 +197,7 @@ which is the signal that a clock is badly off.
// 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>",
"<base32 ECDSA P-384 public key>",
}
```
@@ -215,7 +215,7 @@ 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
prints a private key (base32) for the vault and a public key (base32) to paste into
`keys.go`. The private key is stored in a password manager and in the admin
service's environment. **If it is lost, no new licenses can be issued for any
existing customer without a server release.** Back it up in two places.
+12 -9
View File
@@ -2,15 +2,18 @@
Seven specs, designed 2026-07-24. Build in this order.
| # | Spec | Ships alone | Blocks |
| # | Spec | Plan | Status |
|---|---|---|---|
| 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 | |
| 0a | [shared-module](2026-07-24-shared-module-design.md) | [plan](../plans/2026-07-24-shared-module.md) | **shipped** |
| 0b | [instance-rename](2026-07-24-instance-rename-design.md) | [plan](../plans/2026-07-24-instance-rename.md) | **shipped**, migration verified on live |
| 1 | [licensing-core](2026-07-24-licensing-core-design.md) | [plan](../plans/2026-07-24-licensing-core.md) | planned |
| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | planned |
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | | blocks 4 and 5 |
| 4 | [admin-site](2026-07-24-admin-site-design.md) | | needs 3 |
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | | needs 3 |
Specs 1 and 2 together give working licensing with licences cut by hand with
`lkctl` — no admin service needed. 4 and 5 can run in parallel once 3 lands.
4 and 5 can run in parallel once 3 lands.
@@ -28,7 +31,7 @@ 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
**Licences are offline-verified signed blobs.** ECDSA P-384 with SHA-256 via
`github.com/hyperboloide/lk`, public key compiled into the server, no phone-home
anywhere. This buys air-gapped self-hosting and means no Vantage instance ever
depends on the licensing service being up. It costs revocation: a licence is
+4 -2
View File
@@ -1,16 +1,18 @@
cloud.google.com/go/compute v1.25.1/go.mod h1:oopOIR53ly6viBYxaDhBfJwzUAxf1zE//uf3IB011ls=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
github.com/cncf/xds/go v0.0.0-20240318125728-8a4994d93e50/go.mod h1:5e1+Vvlzido69INQaVO6d87Qn543Xr6nooe9Kz7oBFM=
github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0=
github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew=
github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/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=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
+7
View File
@@ -59,6 +59,13 @@ func main() {
log.Fatalf("scoped collection check failed: %v", assertErr)
}
gfCtx, gfCancel := context.WithTimeout(context.Background(), 2*time.Minute)
gfErr := services.MigrateGrandfatherLicences(gfCtx, db.Database)
gfCancel()
if gfErr != nil {
log.Fatalf("licence grandfather migration failed: %v", gfErr)
}
if err := services.EnsureAuthIndexes(); err != nil {
log.Fatalf("failed to ensure auth indexes: %v", err)
}
-87
View File
@@ -1,87 +0,0 @@
// 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")
}
+2
View File
@@ -14,6 +14,8 @@ require (
google.golang.org/grpc v1.64.0
)
require github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
+4 -1
View File
@@ -43,6 +43,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0=
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
@@ -82,8 +84,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+3
View File
@@ -39,6 +39,9 @@ func createChannel(c *gin.Context) {
}
created, err := services.CreateChannel(auth.InstanceID(c), &ch)
if err != nil {
if limitStatus(c, err) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+17 -4
View File
@@ -37,7 +37,14 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup := r.Group("/api")
apiGroup.Use(auth.Middleware())
// Deny by default: every non-GET route under /api is gated unless it is on
// the exemption list in licence.go. A route added later is covered because
// of where it is mounted, not because someone remembered.
apiGroup.Use(RequireActiveLicense())
{
apiGroup.GET("/license", getLicence)
apiGroup.POST("/license", auth.RequireRole("owner"), postLicence)
apiGroup.GET("/servers", listServers)
apiGroup.POST("/servers", createServer)
apiGroup.GET("/servers/new", newServer)
@@ -76,8 +83,8 @@ func RegisterRoutes(r *gin.Engine) {
apiGroup.POST("/keys/:id/assign", assignKey)
apiGroup.DELETE("/keys/:id/assign/:serverId", revokeAssignment)
apiGroup.POST("/console/connect", consoleConnect)
apiGroup.GET("/console/tunnel", consoleTunnel)
apiGroup.POST("/console/connect", RequireFeature("console"), consoleConnect)
apiGroup.GET("/console/tunnel", RequireFeature("console"), consoleTunnel)
registerWorkflowRoutes(apiGroup)
registerMonitorRoutes(apiGroup)
@@ -90,8 +97,8 @@ func RegisterRoutes(r *gin.Engine) {
instance.POST("/users", createInstanceUser)
instance.PUT("/users/:id/role", updateInstanceUserRole)
instance.DELETE("/users/:id", deleteInstanceUser)
instance.GET("/oidc", getInstanceOIDC)
instance.PUT("/oidc", putInstanceOIDC)
instance.GET("/oidc", RequireFeature("oidc"), getInstanceOIDC)
instance.PUT("/oidc", RequireFeature("oidc"), putInstanceOIDC)
}
}
}
@@ -108,6 +115,9 @@ func listServers(c *gin.Context) {
func createServer(c *gin.Context) {
s, token, err := services.CreateServer(auth.InstanceID(c))
if err != nil {
if limitStatus(c, err) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -121,6 +131,9 @@ func createServer(c *gin.Context) {
func newServer(c *gin.Context) {
s, token, err := services.CreateServer(auth.InstanceID(c))
if err != nil {
if limitStatus(c, err) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -145,7 +145,7 @@ func putInstanceOIDC(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.SaveOrgOIDC(auth.InstanceID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil {
if err := services.SaveInstanceOIDC(auth.InstanceID(c), body.Issuer, body.ClientID, body.ClientSecret, body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+227
View File
@@ -0,0 +1,227 @@
package api
import (
"errors"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/mrhid6/vantage/server/internal/auth"
"github.com/mrhid6/vantage/server/internal/services"
"github.com/mrhid6/vantage/shared/license"
)
// licenceExemptPaths are routes that must work while a licence is expired or
// missing, because they are how a customer recovers or stays safe.
//
// /api/license pasting a valid licence is the way out of degraded mode
// apply-updates security patching is never paywalled
//
// All DELETE requests are exempt separately (see RequireActiveLicense): a
// customer downgraded below their current usage must be able to delete their
// way back under the cap.
var licenceExemptPaths = map[string]bool{
"/api/license": true,
}
// mutatingGETs are routes that change state despite their method. GET is
// otherwise always allowed through, so these have to be named explicitly:
// GET /api/servers/new mints a pre-registration token, which is a creation.
var mutatingGETs = map[string]bool{
"/api/servers/new": true,
}
func licenceExempt(c *gin.Context) bool {
if c.Request.Method == http.MethodDelete {
return true
}
if licenceExemptPaths[c.FullPath()] {
return true
}
if c.FullPath() == "/api/servers/:id/apply-updates" {
return true
}
return false
}
// RequireActiveLicense blocks mutating requests when the licence is not valid.
//
// Mounted on the /api group, so a route added tomorrow is gated because of where
// it lives rather than because someone remembered. GET and HEAD always pass —
// reading is never blocked.
func RequireActiveLicense() gin.HandlerFunc {
return func(c *gin.Context) {
if (c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead) &&
!mutatingGETs[c.FullPath()] {
c.Next()
return
}
if licenceExempt(c) {
c.Next()
return
}
st := services.GetLicenseState(auth.InstanceID(c))
if st.Active() {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "license_required",
"state": st.Status,
"reason": st.Reason,
})
}
}
// RequireFeature blocks a route when the licence does not grant a feature.
func RequireFeature(name string) gin.HandlerFunc {
return func(c *gin.Context) {
st := services.GetLicenseState(auth.InstanceID(c))
if st.Feature(name) {
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "feature_unavailable",
"feature": name,
})
}
}
type licenceResponse struct {
InstanceID string `json:"instance_id"`
State license.State `json:"state"`
Reason string `json:"reason,omitempty"`
Tier string `json:"tier,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
DaysRemaining *int `json:"days_remaining,omitempty"`
Limits license.Limits `json:"limits"`
Features map[string]bool `json:"features"`
Usage licenceUsageResponse `json:"usage"`
Source string `json:"source"`
}
type licenceUsageResponse struct {
Servers int `json:"servers"`
SecretGroups int `json:"secret_groups"`
Channels int `json:"channels"`
}
func getLicence(c *gin.Context) {
instanceID := auth.InstanceID(c)
st := services.GetLicenseState(instanceID)
servers, groups, channels := services.LicenseUsage(instanceID)
resp := licenceResponse{
InstanceID: instanceID,
State: st.Status,
Reason: st.Reason,
Tier: st.Tier,
ExpiresAt: st.ExpiresAt,
Limits: st.Limits,
Features: st.Features,
Usage: licenceUsageResponse{Servers: servers, SecretGroups: groups, Channels: channels},
Source: st.Source,
}
if st.ExpiresAt != nil {
d := int(time.Until(*st.ExpiresAt).Hours() / 24)
resp.DaysRemaining = &d
}
c.JSON(http.StatusOK, resp)
}
var (
licencePostMu sync.Mutex
licencePostCounts = map[string][]time.Time{}
)
const licencePostLimit = 10
// licencePostAllowed permits 10 attempts per instance per hour.
func licencePostAllowed(instanceID string) bool {
cutoff := time.Now().Add(-time.Hour)
licencePostMu.Lock()
defer licencePostMu.Unlock()
kept := licencePostCounts[instanceID][:0]
for _, t := range licencePostCounts[instanceID] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
if len(kept) >= licencePostLimit {
licencePostCounts[instanceID] = kept
return false
}
licencePostCounts[instanceID] = append(kept, time.Now())
return true
}
func postLicence(c *gin.Context) {
instanceID := auth.InstanceID(c)
if !licencePostAllowed(instanceID) {
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "Too many licence attempts. Try again later.",
})
return
}
var body struct {
Blob string `json:"blob"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "a licence key is required"})
return
}
st, err := services.StoreLicense(instanceID, body.Blob)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": licenceRejectionMessage(err.Error(), instanceID),
"reason": err.Error(),
})
return
}
services.LogEvent(instanceID, "license.updated", actorFromCtx(c), "", "",
"licence accepted (tier "+st.Tier+")")
c.JSON(http.StatusOK, gin.H{"state": st.Status, "tier": st.Tier, "expires_at": st.ExpiresAt})
}
// licenceRejectionMessage turns a machine reason into something a person can act
// on. The instance ID is included in the mismatch case because that is the one
// piece of information the customer needs and cannot guess.
func licenceRejectionMessage(reason, instanceID string) string {
switch reason {
case license.ReasonBadSignature:
return "This licence key is not valid. Check it was copied in full."
case license.ReasonDeploymentMismatch:
return "This licence is for Vantage Cloud and cannot be used on a self-hosted install."
case license.ReasonInstanceMismatch:
return "This licence was issued for a different instance. Your instance ID is " + instanceID + "."
case license.ReasonNoLicense:
return "No licence key was provided."
default:
return "This licence could not be accepted."
}
}
// limitStatus maps a LimitError to a 403 body. Handlers that create countable
// resources call this so the UI gets a machine-readable limit name.
func limitStatus(c *gin.Context, err error) bool {
var le *services.LimitError
if !errors.As(err, &le) {
return false
}
c.JSON(http.StatusForbidden, gin.H{
"error": "limit_exceeded",
"limit": le.Limit,
"current": le.Current,
"max": le.Max,
})
return true
}
+6
View File
@@ -91,6 +91,9 @@ func createSecretGroup(c *gin.Context) {
}
}
if err := services.UpsertSecrets(auth.InstanceID(c), body.Group, body.Values); err != nil {
if limitStatus(c, err) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -134,6 +137,9 @@ func putSecretGroup(c *gin.Context) {
}
}
if err := services.UpsertSecrets(auth.InstanceID(c), group, values); err != nil {
if limitStatus(c, err) {
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+17 -17
View File
@@ -11,17 +11,17 @@ import (
"github.com/mrhid6/vantage/server/internal/services"
)
type cachedOrg struct {
org *models.Instance
at time.Time
type cachedInstance struct {
instance *models.Instance
at time.Time
}
var (
orgCacheMu sync.Mutex
orgCache = map[string]cachedOrg{}
instanceCacheMu sync.Mutex
instanceCache = map[string]cachedInstance{}
)
const orgCacheTTL = 60 * time.Second
const instanceCacheTTL = 60 * time.Second
func appRootLabel() string {
if v := os.Getenv("APP_ROOT_LABEL"); v != "" {
@@ -55,20 +55,20 @@ func InstanceFromHost(c *gin.Context) (*models.Instance, bool) {
if slug == "" {
return nil, false
}
orgCacheMu.Lock()
if e, ok := orgCache[slug]; ok && time.Since(e.at) < orgCacheTTL {
orgCacheMu.Unlock()
return e.org, e.org != nil
instanceCacheMu.Lock()
if e, ok := instanceCache[slug]; ok && time.Since(e.at) < instanceCacheTTL {
instanceCacheMu.Unlock()
return e.instance, e.instance != nil
}
orgCacheMu.Unlock()
instanceCacheMu.Unlock()
org, err := services.GetInstanceBySlug(slug)
if err != nil || org == nil {
inst, err := services.GetInstanceBySlug(slug)
if err != nil || inst == nil {
return nil, false
}
orgCacheMu.Lock()
orgCache[slug] = cachedOrg{org: org, at: time.Now()}
orgCacheMu.Unlock()
return org, true
instanceCacheMu.Lock()
instanceCache[slug] = cachedInstance{instance: inst, at: time.Now()}
instanceCacheMu.Unlock()
return inst, true
}
+9 -5
View File
@@ -83,17 +83,17 @@ func HandleBootstrap(c *gin.Context) {
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceName == "" || body.Email == "" || len(body.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": "org_name, email, and password (>=8 chars) required"})
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_name, email, and password (>=8 chars) required"})
return
}
orgCount, err := services.CountInstances()
instanceCount, err := services.CountInstances()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var inst *models.Instance
switch orgCount {
switch instanceCount {
case 0:
inst, err = services.CreateInstance(body.InstanceName)
case 1:
@@ -106,7 +106,7 @@ func HandleBootstrap(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf(
"cannot bootstrap: %d organizations already exist but no users do; "+
"create the owner against the intended inst rather than through setup, "+
"or remove the unintended orgs and retry", orgCount)})
"or remove the unintended orgs and retry", instanceCount)})
return
}
if err != nil {
@@ -126,7 +126,11 @@ func HandleBootstrap(c *gin.Context) {
return
}
SetSessionCookie(c, sessionID)
c.JSON(http.StatusCreated, gin.H{"instance": inst, "slug": inst.Slug})
c.JSON(http.StatusCreated, gin.H{
"instance": inst,
"slug": inst.Slug,
"instance_id": inst.InstanceID,
})
}
func HandleMe(c *gin.Context) {
+12 -6
View File
@@ -32,7 +32,7 @@ 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, instanceID string) (*oidc.Provider, *oauth2.Config, error) {
func providerForInstance(ctx context.Context, c *gin.Context, instanceID string) (*oidc.Provider, *oauth2.Config, error) {
cfg, err := services.GetInstanceOIDC(instanceID)
if err != nil || !cfg.Enabled {
return nil, nil, fmt.Errorf("inst SSO not configured")
@@ -63,11 +63,17 @@ func providerForOrg(ctx context.Context, c *gin.Context, instanceID string) (*oi
func HandleOIDCStart(c *gin.Context) {
inst, ok := InstanceFromHost(c)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown organization host"})
c.JSON(http.StatusBadRequest, gin.H{"error": "unknown instance host"})
return
}
// Losing the feature stops new SSO logins. It deliberately does not touch
// session validation, so nobody is evicted mid-session.
if !services.GetLicenseState(inst.InstanceID).Feature("oidc") {
c.Redirect(http.StatusFound, "/login?error=oidc_unavailable")
return
}
ctx := c.Request.Context()
_, oauthCfg, err := providerForOrg(ctx, c, inst.InstanceID)
_, oauthCfg, err := providerForInstance(ctx, c, inst.InstanceID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -77,7 +83,7 @@ func HandleOIDCStart(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state gen failed"})
return
}
if err := SaveStateOrg(ctx, state, inst.InstanceID); err != nil {
if err := SaveStateInstance(ctx, state, inst.InstanceID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "state save failed"})
return
}
@@ -86,12 +92,12 @@ func HandleOIDCStart(c *gin.Context) {
func HandleOIDCCallback(c *gin.Context) {
ctx := c.Request.Context()
instanceID, ok := ConsumeStateOrg(ctx, c.Query("state"))
instanceID, ok := ConsumeStateInstance(ctx, c.Query("state"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"})
return
}
provider, oauthCfg, err := providerForOrg(ctx, c, instanceID)
provider, oauthCfg, err := providerForInstance(ctx, c, instanceID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
+2 -2
View File
@@ -71,11 +71,11 @@ func DeleteSession(ctx context.Context, id string) error {
return rdb.Del(ctx, sessionPrefix+id).Err()
}
func SaveStateOrg(ctx context.Context, state, instanceID string) error {
func SaveStateInstance(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) {
func ConsumeStateInstance(ctx context.Context, state string) (string, bool) {
instanceID, err := rdb.GetDel(ctx, statePrefix+state).Result()
if err != nil || instanceID == "" {
return "", false
+10
View File
@@ -26,6 +26,16 @@ type vantageServer struct {
pb.UnimplementedVantageServer
}
// Register carries no licence check, deliberately.
//
// A server row only ever comes from CreateServer, which checks the cap before
// issuing a pre-registration token. By the time an agent calls Register its row
// already exists, so counting here would count the caller itself: an instance
// sitting exactly at its cap would reject the very agent it just authorised, and
// every re-registration after a reinstall would fail too.
//
// The cap is enforced where rows are created, which is the only place it can be
// enforced correctly.
func (s *vantageServer) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) {
agentToken, err := services.RegisterServer(req.ServerId, req.PreRegToken, req.Hostname, req.IpAddress, req.OsInfo)
if err != nil {
@@ -6,7 +6,7 @@ import (
"go.mongodb.org/mongo-driver/v2/bson"
)
type OrgOIDC struct {
type InstanceOIDC struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
Issuer string `bson:"issuer" json:"issuer"`
@@ -27,6 +27,11 @@ func loop(ctx context.Context) {
active := map[string]*runner{}
var mu sync.Mutex
// Monitors run regardless of licence state, deliberately.
//
// A customer whose card failed must not lose the ability to know their
// infrastructure is on fire. Creating and editing monitors is blocked by the
// API gate; executing the ones that already exist is not.
sync := func() {
monitors, err := services.ListServerScheduledMonitors()
if err != nil {
+3
View File
@@ -72,6 +72,9 @@ func validateChannelIDs(instanceID string, channelIDs []string) error {
}
func CreateChannel(instanceID string, ch *models.NotificationChannel) (*models.NotificationChannel, error) {
if err := CheckChannelLimit(instanceID); err != nil {
return nil, err
}
ctx, cancel := monCtx()
defer cancel()
ch.InstanceID = instanceID
+3 -3
View File
@@ -10,10 +10,10 @@ import (
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func GetInstanceOIDC(instanceID string) (*models.OrgOIDC, error) {
func GetInstanceOIDC(instanceID string) (*models.InstanceOIDC, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var o models.OrgOIDC
var o models.InstanceOIDC
err := db.Col("instance_oidc").FindOne(ctx, bson.M{"instance_id": instanceID}).Decode(&o)
if err != nil {
return nil, err
@@ -29,7 +29,7 @@ func GetInstanceOIDCSecret(instanceID string) (string, error) {
return decryptString(o.ClientSecretEnc)
}
func SaveOrgOIDC(instanceID, issuer, clientID, clientSecret string, enabled bool) error {
func SaveInstanceOIDC(instanceID, issuer, clientID, clientSecret string, enabled bool) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
set := bson.M{
+1 -1
View File
@@ -92,7 +92,7 @@ func AdoptInstance(instanceID, name string) (*models.Instance, error) {
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, fmt.Errorf("instance slug already taken")
}
return nil, err
}
+175
View File
@@ -0,0 +1,175 @@
package services
import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
// LicenseState is the resolved licence for one instance.
type LicenseState struct {
Status license.State `json:"state"`
Reason string `json:"reason,omitempty"`
Tier string `json:"tier,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Limits license.Limits `json:"limits"`
Features map[string]bool `json:"features"`
// Source is "stored", "env" or "none" — useful when a self-hosted operator
// asks why the licence they pasted is not the one in effect.
Source string `json:"source"`
}
// Active reports whether mutations are allowed.
func (s LicenseState) Active() bool { return s.Status == license.StateValid }
// Feature reports whether a named feature is granted.
func (s LicenseState) Feature(name string) bool { return s.Features[name] }
// DeploymentMode is how this install describes itself to the verifier.
//
// It defaults to self_hosted, the stricter mode. An operator who removes the
// variable gets the tighter behaviour, not the looser one.
func DeploymentMode() string {
if strings.ToLower(os.Getenv("VANTAGE_DEPLOYMENT")) == license.DeploymentCloud {
return license.DeploymentCloud
}
return license.DeploymentSelfHosted
}
type cachedLicense struct {
state LicenseState
at time.Time
}
var (
licenseCacheMu sync.Mutex
licenseCache = map[string]cachedLicense{}
)
const licenseCacheTTL = 60 * time.Second
// InvalidateLicenseCache drops the cached state for one instance, so a pasted
// licence takes effect immediately rather than within the TTL.
func InvalidateLicenseCache(instanceID string) {
licenseCacheMu.Lock()
delete(licenseCache, instanceID)
licenseCacheMu.Unlock()
}
// GetLicenseState resolves the licence for an instance, cached for 60 seconds.
//
// Resolution order:
//
// 1. the blob stored on the instance document
// 2. VANTAGE_LICENSE, used ONLY when the instance has no stored blob, so an
// automated self-hosted deployment can ship a licence without a human
// pasting one
// 3. neither -> invalid / no_license
//
// A blob stored through the UI always wins afterwards, so an operator is never
// locked out by a stale environment value.
func GetLicenseState(instanceID string) LicenseState {
licenseCacheMu.Lock()
if e, ok := licenseCache[instanceID]; ok && time.Since(e.at) < licenseCacheTTL {
licenseCacheMu.Unlock()
return e.state
}
licenseCacheMu.Unlock()
state := resolveLicenseState(instanceID)
licenseCacheMu.Lock()
licenseCache[instanceID] = cachedLicense{state: state, at: time.Now()}
licenseCacheMu.Unlock()
return state
}
func resolveLicenseState(instanceID string) LicenseState {
inst, err := GetInstance(instanceID)
if err != nil {
return LicenseState{
Status: license.StateInvalid,
Reason: license.ReasonNoLicense,
Features: map[string]bool{},
Source: "none",
}
}
blob, source := inst.LicenseBlob, "stored"
if blob == "" {
blob, source = os.Getenv("VANTAGE_LICENSE"), "env"
}
if blob == "" {
return LicenseState{
Status: license.StateInvalid,
Reason: license.ReasonNoLicense,
Features: map[string]bool{},
Source: "none",
}
}
res := license.Verify(blob, license.VerifyOpts{
InstanceID: instanceID,
Deployment: DeploymentMode(),
})
return stateFromResult(res, source)
}
func stateFromResult(res license.Result, source string) LicenseState {
feats := map[string]bool{}
for _, f := range res.License.Features {
feats[f] = true
}
s := LicenseState{
Status: res.State,
Reason: res.Reason,
Tier: res.License.Tier,
Limits: res.License.Limits,
Features: feats,
Source: source,
}
if !res.License.ExpiresAt.IsZero() {
exp := res.License.ExpiresAt
s.ExpiresAt = &exp
}
return s
}
// StoreLicense verifies a blob against this instance and stores it.
//
// An expired-but-otherwise-valid blob IS stored, so the UI can show what expired
// and when. An invalid blob is rejected and the previous one kept.
func StoreLicense(instanceID, blob string) (LicenseState, error) {
blob = strings.TrimSpace(blob)
res := license.Verify(blob, license.VerifyOpts{
InstanceID: instanceID,
Deployment: DeploymentMode(),
})
if res.State == license.StateInvalid {
return LicenseState{}, fmt.Errorf("%s", res.Reason)
}
set := bson.M{
"license_blob": blob,
"license_tier": res.License.Tier,
"license_expiry": res.License.ExpiresAt,
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := db.Col("instances").UpdateOne(ctx,
bson.M{"instance_id": instanceID}, bson.M{"$set": set}); err != nil {
return LicenseState{}, err
}
InvalidateLicenseCache(instanceID)
return stateFromResult(res, "stored"), nil
}
+111
View File
@@ -0,0 +1,111 @@
package services
import (
"context"
"fmt"
"time"
"github.com/mrhid6/vantage/server/internal/db"
"github.com/mrhid6/vantage/shared/license"
"go.mongodb.org/mongo-driver/v2/bson"
)
// LimitError is returned when a licence cap would be exceeded. The API maps it
// to 403 with a machine-readable body.
type LimitError struct {
Limit string
Current int
Max int
}
func (e *LimitError) Error() string {
return fmt.Sprintf("licence limit reached: %s (%d of %d)", e.Limit, e.Current, e.Max)
}
func limitCtx() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 5*time.Second)
}
// CheckServerLimit refuses a new server when the instance is at its cap.
//
// Counts live rows only. An instance already over its cap keeps every server it
// has — nothing is truncated — it simply cannot add another.
func CheckServerLimit(instanceID string) error {
st := GetLicenseState(instanceID)
ctx, cancel := limitCtx()
defer cancel()
n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID})
if err != nil {
return err
}
if !license.WithinLimit(int(n), st.Limits.MaxServers) {
return &LimitError{Limit: "max_servers", Current: int(n), Max: st.Limits.MaxServers}
}
return nil
}
// CheckSecretGroupLimit refuses a NEW group at the cap. Writing to a group that
// already exists is always allowed, so a capped customer can still rotate the
// secrets they have.
func CheckSecretGroupLimit(instanceID, group string) error {
st := GetLicenseState(instanceID)
ctx, cancel := limitCtx()
defer cancel()
existing, err := db.Col("secrets").CountDocuments(ctx,
bson.M{"instance_id": instanceID, "group": group})
if err != nil {
return err
}
if existing > 0 {
return nil
}
var groups []string
if err := db.Col("secrets").Distinct(ctx, "group",
bson.M{"instance_id": instanceID}).Decode(&groups); err != nil {
return err
}
if !license.WithinLimit(len(groups), st.Limits.MaxSecretGroups) {
return &LimitError{Limit: "max_secret_groups", Current: len(groups), Max: st.Limits.MaxSecretGroups}
}
return nil
}
// CheckChannelLimit refuses a new notification channel at the cap.
func CheckChannelLimit(instanceID string) error {
st := GetLicenseState(instanceID)
ctx, cancel := limitCtx()
defer cancel()
n, err := db.Col("notification_channels").CountDocuments(ctx, bson.M{"instance_id": instanceID})
if err != nil {
return err
}
if !license.WithinLimit(int(n), st.Limits.MaxChannels) {
return &LimitError{Limit: "max_channels", Current: int(n), Max: st.Limits.MaxChannels}
}
return nil
}
// LicenseUsage reports current counts, so the UI can say "12 of 3 servers"
// honestly when an instance is over its cap rather than pretending.
func LicenseUsage(instanceID string) (servers, secretGroups, channels int) {
ctx, cancel := limitCtx()
defer cancel()
if n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil {
servers = int(n)
}
var groups []string
if err := db.Col("secrets").Distinct(ctx, "group",
bson.M{"instance_id": instanceID}).Decode(&groups); err == nil {
secretGroups = len(groups)
}
if n, err := db.Col("notification_channels").CountDocuments(ctx,
bson.M{"instance_id": instanceID}); err == nil {
channels = int(n)
}
return
}
+102
View File
@@ -0,0 +1,102 @@
package services
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"time"
"github.com/mrhid6/vantage/shared/license"
"github.com/mrhid6/vantage/shared/models"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// MigrateGrandfatherLicences stores pre-issued licences on instances that have
// none. Cloud only.
//
// The blobs are supplied through VANTAGE_GRANDFATHER_BLOBS, a JSON object
// mapping instance_id to licence blob, because this process cannot sign: it
// holds no private key and the signing code is compiled out. Cut the blobs
// beforehand with lkctl.
//
// The variable is single-use. Unset it on the next deploy.
func MigrateGrandfatherLicences(ctx context.Context, db *mongo.Database) error {
const marker = "0005_grandfather_licences"
if n, _ := db.Collection("migrations").CountDocuments(ctx, bson.M{"_id": marker}); n > 0 {
return nil
}
if DeploymentMode() != license.DeploymentCloud {
log.Printf("0005: not a cloud deployment, skipping")
return nil
}
raw := os.Getenv("VANTAGE_GRANDFATHER_BLOBS")
if raw == "" {
log.Printf("0005: VANTAGE_GRANDFATHER_BLOBS not set, skipping (no marker recorded)")
return nil
}
var blobs map[string]string
if err := json.Unmarshal([]byte(raw), &blobs); err != nil {
return fmt.Errorf("0005: VANTAGE_GRANDFATHER_BLOBS is not valid JSON: %w", err)
}
cur, err := db.Collection("instances").Find(ctx, bson.M{
"$or": []bson.M{
{"license_blob": bson.M{"$exists": false}},
{"license_blob": ""},
},
})
if err != nil {
return fmt.Errorf("0005: list instances: %w", err)
}
var instances []models.Instance
if err := cur.All(ctx, &instances); err != nil {
return fmt.Errorf("0005: decode instances: %w", err)
}
var stored, missing int
for _, inst := range instances {
blob, ok := blobs[inst.InstanceID]
if !ok || blob == "" {
log.Printf("0005: no blob supplied for instance %s (%s)", inst.InstanceID, inst.Slug)
missing++
continue
}
res := license.Verify(blob, license.VerifyOpts{
InstanceID: inst.InstanceID,
Deployment: license.DeploymentCloud,
})
if res.State == license.StateInvalid {
return fmt.Errorf("0005: blob for instance %s is rejected: %s", inst.InstanceID, res.Reason)
}
if _, err := db.Collection("instances").UpdateOne(ctx,
bson.M{"instance_id": inst.InstanceID},
bson.M{"$set": bson.M{
"license_blob": blob,
"license_tier": res.License.Tier,
"license_expiry": res.License.ExpiresAt,
}}); err != nil {
return fmt.Errorf("0005: store blob for %s: %w", inst.InstanceID, err)
}
log.Printf("0005: stored %s licence for instance %s (%s), expires %s",
res.License.Tier, inst.InstanceID, inst.Slug,
res.License.ExpiresAt.Format(time.RFC3339))
stored++
}
if missing > 0 {
return fmt.Errorf("0005: %d instance(s) had no blob supplied; issue them with lkctl and rerun", missing)
}
_, err = db.Collection("migrations").InsertOne(ctx,
bson.M{"_id": marker, "applied_at": time.Now()})
log.Printf("0005: grandfathered %d instance(s)", stored)
return err
}
+2 -2
View File
@@ -52,7 +52,7 @@ func ListMonitors(instanceID string) ([]models.Monitor, error) {
func ListMonitorsForRunner(instanceID, runner string) ([]models.Monitor, error) {
if instanceID == "" {
return nil, errors.New("org id required")
return nil, errors.New("instance id required")
}
return listMonitorsForRunner(instanceID, runner)
}
@@ -227,7 +227,7 @@ func UptimeRollups(instanceID, monitorID string, since time.Time) ([]models.Roll
func IngestResult(instanceID, runner, monitorID string, res checker.Result) error {
if instanceID == "" {
return errors.New("org id required")
return errors.New("instance id required")
}
return ingestResult(instanceID, runner, monitorID, res)
}
+3
View File
@@ -128,6 +128,9 @@ func RevealSecret(instanceID, group, key string) (string, error) {
}
func UpsertSecrets(instanceID, group string, values map[string]string) error {
if err := CheckSecretGroupLimit(instanceID, group); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
+3
View File
@@ -31,6 +31,9 @@ func HashToken(token string) string {
}
func CreateServer(instanceID string) (*models.Server, string, error) {
if err := CheckServerLimit(instanceID); err != nil {
return nil, "", err
}
token, err := generateToken(32)
if err != nil {
return nil, "", err
@@ -202,6 +202,9 @@ func runServer(instanceID, runID string, srvIdx int, steps []models.ResolvedStep
allSecrets := map[string]string{}
serverFailed := false
// A run already in flight when the licence expires finishes its remaining
// steps. New runs are blocked at the API, but killing a workflow midway leaves
// a server in a half-configured state, which is worse than letting it complete.
for i, step := range steps {
startStep(runID, serverID, i, "running")
stepStart := time.Now()
+176
View File
@@ -0,0 +1,176 @@
// Command lkctl issues and inspects Vantage licences by hand.
//
// lkctl keypair
// lkctl issue --instance-id=<uuid> --instance-name="Acme" --tier=professional --term=1y
// lkctl inspect <blob-or-file>
//
// issue reads the signing key from LICENSE_SIGNING_KEY.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
"github.com/google/uuid"
"github.com/hyperboloide/lk"
"github.com/mrhid6/vantage/shared/license"
)
func main() {
if len(os.Args) < 2 {
usage()
}
switch os.Args[1] {
case "keypair":
keypair()
case "issue":
issue(os.Args[2:])
case "inspect":
inspect(os.Args[2:])
default:
usage()
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: lkctl keypair | issue | inspect")
os.Exit(2)
}
func keypair() {
priv, err := lk.NewPrivateKey()
if err != nil {
fatal("generate key: %v", err)
}
privStr, err := priv.ToB32String()
if err != nil {
fatal("encode private key: %v", err)
}
// PublicKey.ToB32String returns one value, unlike its private counterpart.
pubStr := priv.GetPublicKey().ToB32String()
fmt.Println("PRIVATE KEY (store in a password manager and in the admin service's")
fmt.Println("LICENSE_SIGNING_KEY; back it up in two places, it cannot be recovered):")
fmt.Println()
fmt.Println(privStr)
fmt.Println()
fmt.Println("PUBLIC KEY (paste into trustedPublicKeys in shared/license/keys.go):")
fmt.Println()
fmt.Println(pubStr)
}
func issue(args []string) {
fs := flag.NewFlagSet("issue", flag.ExitOnError)
instanceID := fs.String("instance-id", "", "instance UUID the licence is bound to (required)")
instanceName := fs.String("instance-name", "", "display name")
accountID := fs.String("account-id", "", "admin-side account id, optional")
tier := fs.String("tier", "", "free | professional | self_hosted (required)")
term := fs.String("term", "1y", "1m or 1y")
expires := fs.String("expires", "", "explicit RFC3339 expiry, overrides --term")
out := fs.String("out", "", "write the blob to this file instead of stdout")
fs.Parse(args)
if *instanceID == "" || *tier == "" {
fatal("--instance-id and --tier are required")
}
plan, ok := license.PlanFor(*tier)
if !ok {
fatal("unknown tier %q", *tier)
}
key := os.Getenv("LICENSE_SIGNING_KEY")
if key == "" {
fatal("LICENSE_SIGNING_KEY is not set")
}
now := time.Now().UTC()
var exp time.Time
switch {
case *expires != "":
t, err := time.Parse(time.RFC3339, *expires)
if err != nil {
fatal("parse --expires: %v", err)
}
exp = t.UTC()
case *term == "1m":
exp = now.AddDate(0, 1, 0)
case *term == "1y":
exp = now.AddDate(1, 0, 0)
default:
fatal("--term must be 1m or 1y")
}
// Self Hosted is sold annually only, so the window in which a cancelled
// licence keeps working is bounded at a year.
if plan.Tier == license.TierSelfHosted && *term == "1m" && *expires == "" {
fatal("self_hosted is annual only; use --term=1y or an explicit --expires")
}
name := *instanceName
if name == "" {
name = *instanceID
}
l := license.License{
ID: uuid.NewString(),
InstanceID: *instanceID,
AccountID: *accountID,
InstanceName: name,
Tier: plan.Tier,
Deployment: plan.Deployment,
IssuedAt: now,
ExpiresAt: exp,
Limits: plan.Limits,
Features: plan.Features,
}
blob, err := license.Sign(l, key)
if err != nil {
fatal("%v", err)
}
if *out != "" {
if err := os.WriteFile(*out, []byte(blob+"\n"), 0o600); err != nil {
fatal("write %s: %v", *out, err)
}
fmt.Fprintf(os.Stderr, "wrote %s (tier=%s deployment=%s expires=%s)\n",
*out, l.Tier, l.Deployment, l.ExpiresAt.Format(time.RFC3339))
return
}
fmt.Println(blob)
}
func inspect(args []string) {
if len(args) < 1 {
fatal("usage: lkctl inspect <blob-or-file>")
}
blob := args[0]
if b, err := os.ReadFile(blob); err == nil {
blob = strings.TrimSpace(string(b))
}
l, err := license.Parse(blob)
if err != nil {
fatal("%v", err)
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(l); err != nil {
fatal("%v", err)
}
if time.Now().After(l.ExpiresAt) {
fmt.Fprintf(os.Stderr, "\nNOTE: expired %s\n", l.ExpiresAt.Format(time.RFC3339))
}
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
+1
View File
@@ -4,6 +4,7 @@ go 1.26.4
require (
github.com/google/uuid v1.6.0
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216
go.mongodb.org/mongo-driver/v2 v2.8.0
golang.org/x/crypto v0.54.0
)
+8
View File
@@ -4,8 +4,14 @@ 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/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 h1:Luh+sE/W2M+V0Y+jlZN7nJefLNHc4/y93xxl+rFD7k0=
github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216/go.mod h1:/OLW9HZj6qtQ7gWTGwuO3JrUZ+MC7I7TLRuNl14TYuo=
github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/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=
@@ -46,3 +52,5 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
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=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+24
View File
@@ -0,0 +1,24 @@
package license
import (
"fmt"
"github.com/hyperboloide/lk"
)
var trustedPublicKeys = []string{
// Production signing key, generated 2026-07-24. Index 0 is current.
"AS6Z4XBXF7HPTOMBUK47SWHPROAGOSBIW5ZZZHTLCA6FHMYSHCCTV3S6AIN6DOB6VKMHMTLRIWPSBAZ2FOHJV3A6NLOCWGEO2VA7KYGSG62SILEFA4SNJ7VWDDIVZZM4ZU4VJVE22LESH72STAAVIDYI77WA====",
}
func publicKeys() ([]*lk.PublicKey, error) {
out := make([]*lk.PublicKey, 0, len(trustedPublicKeys))
for i, s := range trustedPublicKeys {
k, err := lk.PublicKeyFromB32String(s)
if err != nil {
return nil, fmt.Errorf("trusted public key %d is malformed: %w", i, err)
}
out = append(out, k)
}
return out, nil
}
+76
View File
@@ -0,0 +1,76 @@
// Package license defines the Vantage licence payload and its offline
// verification.
//
// A licence is a signed blob (ECDSA P-384 with SHA-256). The server checks a
// signature, an expiry, a deployment mode and an instance ID, and asks nobody's
// permission. That buys air-gapped self-hosting and means no instance depends
// on the licensing service being reachable.
//
// It costs revocation: once issued, a licence is valid until it expires
// whatever the billing system later says. Self Hosted is sold annually only so
// that window is bounded.
package license
import "time"
const (
TierFree = "free"
TierProfessional = "professional"
TierSelfHosted = "self_hosted"
DeploymentCloud = "cloud"
DeploymentSelfHosted = "self_hosted"
FeatureConsole = "console" // browser SSH/RDP/VNC
FeatureOIDC = "oidc" // per-instance single sign-on
)
// Unlimited is the sentinel for "no cap" in every Limits field.
const Unlimited = -1
// Limits are the countable caps a licence grants.
type Limits struct {
MaxServers int `json:"max_servers"`
MaxSecretGroups int `json:"max_secret_groups"`
MaxChannels int `json:"max_channels"`
}
// License is the signed payload.
//
// InstanceID is always populated: the self-hosted purchase flow links the
// instance UUID before the licence is signed, so there is no unbound licence
// and no claim protocol.
type License struct {
ID string `json:"id"` // uuid, for support and audit
InstanceID string `json:"instance_id"` // the instance this licence is bound to
AccountID string `json:"account_id"` // admin-side customer, informational
InstanceName string `json:"instance_name"` // display only
Tier string `json:"tier"`
Deployment string `json:"deployment"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"`
Limits Limits `json:"limits"`
Features []string `json:"features"`
}
// HasFeature reports whether the licence grants a named feature.
//
// Callers must use this rather than switching on Tier. Adding a tier, or
// changing what a tier includes, must never require a server release.
func (l License) HasFeature(name string) bool {
for _, f := range l.Features {
if f == name {
return true
}
}
return false
}
// WithinLimit reports whether one more of something is allowed.
// A max of Unlimited always allows.
func WithinLimit(current, max int) bool {
if max == Unlimited {
return true
}
return current < max
}
+47
View File
@@ -0,0 +1,47 @@
package license
// Plan is the contents of a tier at issue time.
//
// This table is the seed. Once the admin service exists (spec 3) it owns the
// authoritative copy in its `plans` collection, and every issued licence
// snapshots the plan it was cut from — so editing a plan never rewrites an
// existing licence, the same rule as workflow_runs.steps_snapshot.
//
// lkctl uses this table to issue by hand until then.
type Plan struct {
Tier string
Name string
Deployment string
Limits Limits
Features []string
}
var plans = map[string]Plan{
TierFree: {
Tier: TierFree,
Name: "Free",
Deployment: DeploymentCloud, // cloud only, by construction
Limits: Limits{MaxServers: 3, MaxSecretGroups: 1, MaxChannels: 1},
Features: nil,
},
TierProfessional: {
Tier: TierProfessional,
Name: "Professional",
Deployment: DeploymentCloud,
Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
Features: []string{FeatureConsole, FeatureOIDC},
},
TierSelfHosted: {
Tier: TierSelfHosted,
Name: "Self Hosted",
Deployment: DeploymentSelfHosted,
Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
Features: []string{FeatureConsole, FeatureOIDC},
},
}
// PlanFor returns the seed plan for a tier.
func PlanFor(tier string) (Plan, bool) {
p, ok := plans[tier]
return p, ok
}
+43
View File
@@ -0,0 +1,43 @@
//go:build !noSign
package license
import (
"encoding/json"
"fmt"
"github.com/hyperboloide/lk"
)
// Sign marshals a licence and signs it, returning the base32 blob.
//
// This file carries the !noSign build tag so the signing path can be compiled
// out of the control plane. The server has no reason to hold signing code and
// no reason to ship it into a customer's data centre.
//
// privateKeyB32 comes from LICENSE_SIGNING_KEY on the issuing side only.
func Sign(l License, privateKeyB32 string) (string, error) {
if privateKeyB32 == "" {
return "", fmt.Errorf("no signing key provided")
}
priv, err := lk.PrivateKeyFromB32String(privateKeyB32)
if err != nil {
return "", fmt.Errorf("parse signing key: %w", err)
}
data, err := json.Marshal(l)
if err != nil {
return "", fmt.Errorf("marshal licence: %w", err)
}
signed, err := lk.NewLicense(priv, data)
if err != nil {
return "", fmt.Errorf("sign licence: %w", err)
}
blob, err := signed.ToB32String()
if err != nil {
return "", fmt.Errorf("encode licence: %w", err)
}
return blob, nil
}
+132
View File
@@ -0,0 +1,132 @@
package license
import (
"encoding/json"
"fmt"
"time"
"github.com/hyperboloide/lk"
)
type State string
const (
StateValid State = "valid"
StateExpired State = "expired"
StateInvalid State = "invalid"
)
// Reasons a licence is not valid. These are stable identifiers: the API returns
// them and the UI maps them to messages, so do not reword them casually.
const (
ReasonNoLicense = "no_license"
ReasonBadSignature = "bad_signature"
ReasonDeploymentMismatch = "deployment_mismatch"
ReasonInstanceMismatch = "instance_mismatch"
ReasonExpired = "expired"
)
// VerifyOpts is what the verifier knows about itself.
type VerifyOpts struct {
InstanceID string // this instance's own ID; required
Deployment string // "cloud" or "self_hosted"; required
Now time.Time // zero means time.Now()
}
type Result struct {
License License
State State
Reason string
// ClockSkewed is set when IssuedAt is in the future, which usually means
// the host clock is wrong. It does not by itself invalidate the licence.
ClockSkewed bool
}
// Verify checks a licence blob against this instance.
//
// The checks run in a fixed order and stop at the first failure:
//
// 1. signature against a trusted public key -> bad_signature
// 2. deployment matches this install -> deployment_mismatch
// 3. instance ID matches this instance -> instance_mismatch
// 4. not past ExpiresAt -> expired
//
// The order matters. A blob that is both expired and bound to another instance
// reports instance_mismatch, not expired, because that is the more useful thing
// to tell the person holding it.
//
// No clock tolerance is applied. Terms are a month or a year; a host whose clock
// is wrong by enough to matter has larger problems, and a tolerance window is a
// thing to get wrong.
func Verify(blob string, opts VerifyOpts) Result {
if blob == "" {
return Result{State: StateInvalid, Reason: ReasonNoLicense}
}
l, err := Parse(blob)
if err != nil {
return Result{State: StateInvalid, Reason: ReasonBadSignature}
}
res := Result{License: l}
if l.Deployment != opts.Deployment {
res.State, res.Reason = StateInvalid, ReasonDeploymentMismatch
return res
}
if l.InstanceID != opts.InstanceID {
res.State, res.Reason = StateInvalid, ReasonInstanceMismatch
return res
}
now := opts.Now
if now.IsZero() {
now = time.Now()
}
res.ClockSkewed = l.IssuedAt.After(now)
if !now.Before(l.ExpiresAt) {
res.State, res.Reason = StateExpired, ReasonExpired
return res
}
res.State = StateValid
return res
}
// Parse verifies the signature only, ignoring binding and expiry.
//
// Used to display a licence and to inspect a blob a customer has emailed in.
// Never use it for enforcement — it does not check who the licence is for.
func Parse(blob string) (License, error) {
parsed, err := lk.LicenseFromB32String(blob)
if err != nil {
return License{}, fmt.Errorf("licence is not readable: %w", err)
}
keys, err := publicKeys()
if err != nil {
return License{}, err
}
if len(keys) == 0 {
return License{}, fmt.Errorf("this build trusts no licence signing keys")
}
verified := false
for _, k := range keys {
ok, err := parsed.Verify(k)
if err == nil && ok {
verified = true
break
}
}
if !verified {
return License{}, fmt.Errorf("licence signature does not match any trusted key")
}
var l License
if err := json.Unmarshal(parsed.Data, &l); err != nil {
return License{}, fmt.Errorf("licence contents are not readable: %w", err)
}
return l, nil
}
+11
View File
@@ -20,4 +20,15 @@ type Instance struct {
Name string `bson:"name" json:"name"`
Slug string `bson:"slug" json:"slug"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
// LicenseBlob is the authoritative licence. LicenseTier and LicenseExpiry
// are a denormalised cache for listing and for the admin service's queries,
// rewritten from the verified payload every time a blob is accepted.
// Nothing reads them for enforcement.
//
// The blob is json:"-" because there is no reason to spray it through API
// responses. It is signed public data, not a secret.
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"`
}
+5 -1
View File
@@ -1,4 +1,5 @@
import { AuthProvider } from "@/components/AuthProvider";
import { LicenseBanner } from "@/components/LicenseBanner";
import { Sidebar } from "@/components/Sidebar";
export default function AppLayout({
@@ -10,7 +11,10 @@ export default function AppLayout({
<AuthProvider>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">{children}</main>
<main className="flex-1 overflow-y-auto">
<LicenseBanner />
{children}
</main>
</div>
</AuthProvider>
);
+17 -2
View File
@@ -7,6 +7,7 @@ import Link from "next/link";
import { api, ServerStatus, GenerateKeyOptions, PackageUpdate, Inventory } from "@/lib/api";
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { useLicense } from "@/lib/useLicense";
function statusVariant(status: ServerStatus) {
switch (status) {
@@ -307,6 +308,8 @@ export default function ServerDetailPage() {
const [updateSuccess, setUpdateSuccess] = useState(false);
const [showUpdatesModal, setShowUpdatesModal] = useState(false);
const [applySuccess, setApplySuccess] = useState(false);
const { hasFeature } = useLicense();
const consoleAllowed = hasFeature("console");
const {
data: server,
@@ -396,9 +399,21 @@ export default function ServerDetailPage() {
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
</div>
<div className="flex gap-2">
{/* Rendered disabled rather than hidden when the licence does not
include the console: a customer cannot buy what they cannot see,
and a feature that vanishes reads as a bug. */}
{server.console_protocols?.map((p) => (
<Link key={p} href={`/servers/${serverId}/console?protocol=${p}`}>
<Button variant="secondary">
<Link
key={p}
href={consoleAllowed ? `/servers/${serverId}/console?protocol=${p}` : "#"}
aria-disabled={!consoleAllowed}
title={consoleAllowed ? undefined : "Upgrade to use the browser console"}
onClick={(e) => {
if (!consoleAllowed) e.preventDefault();
}}
className={consoleAllowed ? undefined : "pointer-events-none opacity-50"}
>
<Button variant="secondary" disabled={!consoleAllowed}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { licence } from "@/lib/api";
import { useLicense } from "@/lib/useLicense";
function cap(n: number) {
return n === -1 ? "Unlimited" : String(n);
}
export default function LicensePage() {
const { license } = useLicense();
const queryClient = useQueryClient();
const [blob, setBlob] = useState("");
const [error, setError] = useState("");
const save = useMutation({
mutationFn: () => licence.put(blob.trim()),
onSuccess: () => {
setBlob("");
setError("");
queryClient.invalidateQueries({ queryKey: ["license"] });
},
onError: (e: Error) => setError(e.message),
});
if (!license) return null;
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-text-primary">Licence</h1>
<section className="rounded border border-border p-4">
<p className="text-sm text-text-secondary">
State: <b>{license.state}</b>
{license.tier ? <> · Tier: <b>{license.tier}</b></> : null}
{license.expires_at ? <> · Expires {new Date(license.expires_at).toLocaleDateString()}</> : null}
</p>
<p className="mt-2 text-xs text-text-tertiary">
Instance ID quote this when buying or activating a licence
</p>
<div className="mt-1 flex items-center gap-2">
<code className="text-sm">{license.instance_id}</code>
<button
type="button"
className="text-xs underline"
onClick={() => navigator.clipboard.writeText(license.instance_id)}
>
Copy
</button>
</div>
</section>
<section className="rounded border border-border p-4">
<h2 className="font-semibold text-text-primary">Usage</h2>
<ul className="mt-2 space-y-1 text-sm text-text-secondary">
<li>Servers: {license.usage.servers} of {cap(license.limits.max_servers)}</li>
<li>Secret groups: {license.usage.secret_groups} of {cap(license.limits.max_secret_groups)}</li>
<li>Notification channels: {license.usage.channels} of {cap(license.limits.max_channels)}</li>
<li>Browser console: {license.features.console ? "Included" : "Not included"}</li>
<li>Single sign-on: {license.features.oidc ? "Included" : "Not included"}</li>
</ul>
</section>
<section className="rounded border border-border p-4">
<h2 className="font-semibold text-text-primary">Add or replace a licence</h2>
<textarea
className="mt-2 h-32 w-full rounded border border-border bg-transparent p-2 font-mono text-xs"
placeholder="Paste your licence key"
value={blob}
onChange={(e) => setBlob(e.target.value)}
/>
<input
type="file"
accept=".lic,.txt"
className="mt-2 block text-xs"
onChange={async (e) => {
const f = e.target.files?.[0];
if (f) setBlob((await f.text()).trim());
}}
/>
{error ? <p className="mt-2 text-sm text-red-400">{error}</p> : null}
<button
type="button"
className="mt-3 rounded bg-accent px-3 py-1.5 text-sm"
disabled={!blob.trim() || save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? "Checking…" : "Save licence"}
</button>
</section>
</div>
);
}
+17 -2
View File
@@ -39,7 +39,7 @@ export default function SetupPage() {
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [validationError, setValidationError] = useState<string | null>(null);
const [created, setCreated] = useState<{ slug: string; loginUrl: string } | null>(null);
const [created, setCreated] = useState<{ slug: string; loginUrl: string; instanceId: string } | null>(null);
useEffect(() => {
auth.bootstrapStatus()
@@ -56,7 +56,7 @@ export default function SetupPage() {
} = useMutation({
mutationFn: () => auth.bootstrap({ instance_name: instanceName, email, password }),
onSuccess: (res) => {
setCreated({ slug: res.slug, loginUrl: instanceLoginUrlForSlug(res.slug) });
setCreated({ slug: res.slug, loginUrl: instanceLoginUrlForSlug(res.slug), instanceId: res.instance_id });
},
});
@@ -93,6 +93,21 @@ export default function SetupPage() {
you just chose.
</p>
<code className="mt-3 block overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">{created.loginUrl}</code>
<div className="mt-4 rounded border border-border p-3">
<p className="text-xs text-text-tertiary">Instance ID needed to activate a licence</p>
<div className="mt-1 flex items-center gap-2">
<code className="overflow-x-auto font-mono text-xs text-text-primary">{created.instanceId}</code>
<button
type="button"
className="text-xs underline"
onClick={() => navigator.clipboard.writeText(created.instanceId)}
>
Copy
</button>
</div>
</div>
<a href={created.loginUrl} className="mt-5 block">
<Button type="button" variant="primary" className="w-full justify-center">
Go to sign in
+40
View File
@@ -0,0 +1,40 @@
"use client";
import Link from "next/link";
import { useLicense } from "@/lib/useLicense";
export function LicenseBanner() {
const { license } = useLicense();
if (!license) return null;
if (license.state === "expired") {
const when = license.expires_at ? new Date(license.expires_at).toLocaleDateString() : "recently";
return (
<div className="bg-amber-900/40 px-4 py-2 text-sm text-amber-100">
Your Vantage licence expired on {when}. Your servers and monitors are still running,
but changes are disabled until it is renewed.{" "}
<Link href="/settings/license" className="underline">Add a licence</Link>
</div>
);
}
if (license.state === "invalid") {
return (
<div className="bg-red-900/40 px-4 py-2 text-sm text-red-100">
This instance has no valid licence. Changes are disabled.{" "}
<Link href="/settings/license" className="underline">Add a licence</Link>
</div>
);
}
if (typeof license.days_remaining === "number" && license.days_remaining <= 14) {
return (
<div className="bg-amber-900/25 px-4 py-2 text-sm text-amber-100">
Your licence expires in {license.days_remaining} day
{license.days_remaining === 1 ? "" : "s"}.
</div>
);
}
return null;
}
+13
View File
@@ -75,6 +75,18 @@ function AuditIcon() {
);
}
function LicenceIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z"
/>
</svg>
);
}
function SettingsIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -129,6 +141,7 @@ const navItems: NavItem[] = [
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings/instance", label: "Instance", icon: <InstanceIcon />, adminOnly: true },
{ href: "/settings/license", label: "Licence", icon: <LicenceIcon />, adminOnly: true },
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
];
+26
View File
@@ -327,6 +327,7 @@ export interface BootstrapStatus {
export interface BootstrapResponse {
instance: Instance;
slug: string;
instance_id: string;
}
export interface InstanceUser {
@@ -790,3 +791,28 @@ export const api = {
return `/api/runs/${runId}/servers/${serverId}/logs/stream`;
},
};
export type LicenseState = "valid" | "expired" | "invalid";
export interface LicenseInfo {
instance_id: string;
state: LicenseState;
reason?: string;
tier?: string;
expires_at?: string;
days_remaining?: number;
limits: { max_servers: number; max_secret_groups: number; max_channels: number };
features: Record<string, boolean>;
usage: { servers: number; secret_groups: number; channels: number };
source: string;
}
// `request` already prefixes /api, so these paths do not repeat it.
export const licence = {
get(): Promise<LicenseInfo> {
return request<LicenseInfo>("/license");
},
put(blob: string): Promise<{ state: LicenseState; tier: string; expires_at?: string }> {
return request("/license", { method: "POST", body: JSON.stringify({ blob }) });
},
};
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { licence, type LicenseInfo } from "@/lib/api";
export function useLicense() {
const { data, isLoading } = useQuery<LicenseInfo>({
queryKey: ["license"],
queryFn: licence.get,
staleTime: 60_000,
});
return {
license: data,
isLoading,
isActive: data?.state === "valid",
// Features render disabled rather than hidden, so treat "unknown while
// loading" as available to avoid a flash of disabled controls.
hasFeature: (name: string) => (data ? Boolean(data.features?.[name]) : true),
};
}
File diff suppressed because one or more lines are too long