diff --git a/server/internal/api/channels.go b/server/internal/api/channels.go index 6b0114c..d33ec21 100644 --- a/server/internal/api/channels.go +++ b/server/internal/api/channels.go @@ -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 } diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 9157947..c5bb154 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -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 } diff --git a/server/internal/api/licence.go b/server/internal/api/licence.go new file mode 100644 index 0000000..a4386e0 --- /dev/null +++ b/server/internal/api/licence.go @@ -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 +} diff --git a/server/internal/api/secrets.go b/server/internal/api/secrets.go index e65dc69..08ded7b 100644 --- a/server/internal/api/secrets.go +++ b/server/internal/api/secrets.go @@ -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 } diff --git a/server/internal/auth/oidc.go b/server/internal/auth/oidc.go index de8c3ea..c1c470c 100644 --- a/server/internal/auth/oidc.go +++ b/server/internal/auth/oidc.go @@ -63,7 +63,13 @@ func providerForInstance(ctx context.Context, c *gin.Context, instanceID string) 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()