Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb8b85ccfe | ||
|
|
09a39090c6 | ||
|
|
8626898e5e | ||
|
|
ee4dff09c9 | ||
|
|
8d88b16f20 | ||
|
|
120c9c6735 | ||
|
|
855537c535 | ||
|
|
1d3fcebb28 | ||
|
|
f9f382049d | ||
|
|
e4d7569a1c | ||
|
|
c3e363eccc | ||
|
|
6fde319b2f | ||
|
|
4f1fce32c1 | ||
|
|
968408b955 | ||
|
|
95c5d531ae | ||
|
|
b0d8edf9b6 | ||
|
|
a50219c0d9 | ||
|
|
f10fe61916 | ||
|
|
bc1cda26f8 | ||
|
|
f646ce5c47 |
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.
|
||||
|
||||
@@ -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
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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 }) });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user