mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
What such a key may reach was a list of addresses kept in the rule that reads it. The list held Base's own routes and nothing else, so bootnode's twelve reads — which it registers precisely to be reachable that way, and guards with a read-only refusal beside each of its eleven writes — answered 403 wherever the plugin was on. A list of one package's addresses kept in another goes stale the moment that package mounts a route, and it goes stale silently. So the answer moves to the route: apis.Public declares one where it is registered, and the rule holds no addresses at all. Bootnode declares its twelve reads; Base declares its records, rest, files and health reads. The rule keeps one address of its own, the public submit path, because whether it is public is the collection's statement and not the route's. A route needs to know its whole address for that, so the mux records it as it mounts: Route.Pattern is the method, the group prefixes and the path, the same string a request reports having matched. Second layer, restored: bootnode read ReadOnly off the identity headers, which the org plugin writes identically for every key it resolves — so a publishable key arrived as a person and no read-only refusal could fire. The headers say who; the credential says what kind. Co-authored-by: Hanzo Dev <[email protected]>
252 lines
8.6 KiB
Go
252 lines
8.6 KiB
Go
// Package bootnode is the Go port of the Python bootnode backend
|
|
// (bootnode/api/), built natively on Hanzo Base as a plugin.
|
|
//
|
|
// It consolidates the blockchain developer platform — multi-network OAuth,
|
|
// project API keys, team management, and 1-click network/node/key
|
|
// provisioning — onto Base's IAM + base infrastructure. Identity is owned by
|
|
// Hanzo IAM (reused via github.com/hanzoai/base/iam); per-org and per-user data
|
|
// isolation come from the platform plugin's PrincipalIsolation="sqlite" mode;
|
|
// billing is wired through the commerce plugin's Client interface.
|
|
//
|
|
// Network/NodeFleet/KMSSecret provisioning targets bootno.de/v1 custom
|
|
// resources applied via a dependency-free Kubernetes REST client
|
|
// (plugins/bootnode/kube) — no client-go, no CGO.
|
|
//
|
|
// This is a foundation: 5 of 20 API modules are ported end-to-end (auth, team,
|
|
// networks, nodes, keys). The remaining modules are tracked in the PR body.
|
|
package bootnode
|
|
|
|
import (
|
|
"github.com/hanzoai/base/apis"
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/iam"
|
|
"github.com/hanzoai/base/plugins/bootnode/auth"
|
|
"github.com/hanzoai/base/plugins/bootnode/kube"
|
|
"github.com/hanzoai/base/plugins/bootnode/models"
|
|
"github.com/hanzoai/base/plugins/commerce"
|
|
"github.com/hanzoai/base/tools/hook"
|
|
"github.com/hanzoai/base/tools/router"
|
|
luxlog "github.com/luxfi/log"
|
|
)
|
|
|
|
// MustRegister installs the bootnode plugin and panics on error.
|
|
func MustRegister(app core.App, cfg Config) {
|
|
if err := Register(app, cfg); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
// Register installs the bootnode plugin.
|
|
//
|
|
// On OnBootstrap it creates the bootnode collections (idempotent). On OnServe
|
|
// it mounts the API under /v1. A zero-value Config disables the plugin; callers
|
|
// opt in with Enabled:true.
|
|
func Register(app core.App, cfg Config) error {
|
|
if !cfg.Enabled {
|
|
return nil
|
|
}
|
|
cfg.resolve()
|
|
|
|
// Fail fast: never run with the insecure default salt against a real IAM.
|
|
if cfg.isProductionIAM() && cfg.APIKeySalt == insecureSaltDefault {
|
|
return errInsecureSalt
|
|
}
|
|
|
|
p := &plugin{
|
|
app: app,
|
|
config: cfg,
|
|
logger: luxlog.New("component", "bootnode"),
|
|
iam: iam.NewClient(cfg.IAMEndpoint),
|
|
kube: kube.New(cfg.KubeNamespace),
|
|
commerce: commerce.New(commerce.Config{BaseURL: cfg.CommerceURL, APIKey: cfg.CommerceAPIKey}),
|
|
}
|
|
|
|
app.OnBootstrap().Bind(&hook.Handler[*core.BootstrapEvent]{
|
|
Id: "__bootnodeBootstrap__",
|
|
Func: func(e *core.BootstrapEvent) error {
|
|
if err := e.Next(); err != nil {
|
|
return err
|
|
}
|
|
return models.EnsureAll(app)
|
|
},
|
|
})
|
|
|
|
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
|
|
Id: "__bootnodeServe__",
|
|
Func: func(e *core.ServeEvent) error {
|
|
app.Store().Set("bootnode", p)
|
|
p.registerRoutes(e.Router)
|
|
return e.Next()
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// errInsecureSalt is returned when production-mode IAM is paired with the
|
|
// placeholder API key salt.
|
|
var errInsecureSalt = errString("bootnode: BOOTNODE_API_KEY_SALT must be set to a non-default value when IAM_URL is non-local")
|
|
|
|
type errString string
|
|
|
|
func (e errString) Error() string { return string(e) }
|
|
|
|
// plugin holds the shared dependencies for all bootnode handlers.
|
|
type plugin struct {
|
|
app core.App
|
|
config Config
|
|
logger luxlog.Logger
|
|
iam *iam.Client
|
|
kube *kube.Client
|
|
commerce commerce.Client
|
|
}
|
|
|
|
// registerRoutes mounts every bootnode endpoint. One group, /v1, matching the
|
|
// Python api_prefix. No extraneous /api/ segment.
|
|
//
|
|
// The reads are declared public, which is what a pk- key was issued for: it is
|
|
// printed in a page, it carries the page's own org, and every read here answers
|
|
// within that org — the chain catalogue, the project's networks, nodes and key
|
|
// status, its team, the key's own identity. None hands back key material or a
|
|
// credential: a key's material exists only in KMS, the listing shows a prefix,
|
|
// and the handler that mints one is a POST. Every write refuses a publishable
|
|
// key outright, beside the write.
|
|
//
|
|
// The declaration sits here because these are bootnode's routes. It used to sit
|
|
// in the rule that reads it, which meant a list of one package's addresses kept
|
|
// in another, and a dozen of these reads answered 403 the moment that list fell
|
|
// behind.
|
|
func (p *plugin) registerRoutes(r *router.Router[*core.RequestEvent]) {
|
|
g := r.Group("/v1")
|
|
|
|
// auth/
|
|
g.POST("/auth/oauth/callback", p.handleOAuthCallback)
|
|
g.POST("/auth/projects", p.handleCreateProject)
|
|
g.POST("/auth/keys", p.handleCreateAPIKey)
|
|
g.DELETE("/auth/keys/{id}", p.handleDeleteAPIKey)
|
|
apis.Public(
|
|
g.GET("/auth/me", p.handleMe),
|
|
g.GET("/auth/projects/{id}", p.handleGetProject),
|
|
g.GET("/auth/keys", p.handleListAPIKeys),
|
|
)
|
|
|
|
// team/
|
|
g.POST("/team", p.handleInviteMember)
|
|
g.PATCH("/team/{id}", p.handleUpdateMember)
|
|
g.DELETE("/team/{id}", p.handleRemoveMember)
|
|
apis.Public(g.GET("/team", p.handleListTeam))
|
|
|
|
// chains/
|
|
apis.Public(
|
|
g.GET("/chains", p.handleListChains),
|
|
g.GET("/chains/{chain}", p.handleGetChain),
|
|
)
|
|
|
|
// networks/ (bootno.de/v1 Network CRs)
|
|
g.POST("/networks", p.handleCreateNetwork)
|
|
g.DELETE("/networks/{id}", p.handleDeleteNetwork)
|
|
apis.Public(
|
|
g.GET("/networks", p.handleListNetworks),
|
|
g.GET("/networks/{id}", p.handleGetNetwork),
|
|
)
|
|
|
|
// nodes/ (bootno.de/v1 NodeFleet CRs)
|
|
g.POST("/nodes", p.handleCreateNodeFleet)
|
|
g.DELETE("/nodes/{id}", p.handleDeleteNodeFleet)
|
|
apis.Public(
|
|
g.GET("/nodes", p.handleListNodeFleets),
|
|
g.GET("/nodes/{id}", p.handleGetNodeFleet),
|
|
)
|
|
|
|
// keys/ (bootno.de/v1 KMSSecret CRs — no plaintext storage)
|
|
g.POST("/keys", p.handleCreateKMSKey)
|
|
apis.Public(
|
|
g.GET("/keys/status", p.handleKeyStatus),
|
|
g.GET("/keys/{name}", p.handleGetKMSKey),
|
|
)
|
|
}
|
|
|
|
// Identity is the resolved caller context for a request: who they are (IAM
|
|
// user id + org) and, when applicable, the bootnode project they act within.
|
|
type Identity struct {
|
|
UserID string
|
|
Email string
|
|
Org string
|
|
// ReadOnly is true for publishable (pk-) keys.
|
|
ReadOnly bool
|
|
}
|
|
|
|
// requireUser resolves the caller's identity from the request. It accepts, in
|
|
// order: a Base-resolved auth record, an IAM bearer JWT, and IAM-managed
|
|
// pk-/sk-/hk- keys. bootnode bn_ project keys do NOT identify a user — they
|
|
// identify a project (see requireProject). Returns a 401 on failure.
|
|
func (p *plugin) requireUser(e *core.RequestEvent) (*Identity, error) {
|
|
// Identity headers set by the platform plugin / gateway take precedence.
|
|
// They say WHO, and the credential still says WHAT KIND: the plugin that
|
|
// writes them writes the same headers for every IAM key it resolved, so
|
|
// reading only them made a key printed in a web page a caller with a
|
|
// person's privileges and left the read-only refusals below unreachable.
|
|
if uid := e.Request.Header.Get("X-User-Id"); uid != "" {
|
|
return &Identity{
|
|
UserID: uid,
|
|
Email: e.Request.Header.Get("X-User-Email"),
|
|
Org: e.Request.Header.Get("X-Org-Id"),
|
|
ReadOnly: auth.ClassifyCredential(apis.Credential(e.Request)) == auth.KeyIAMPublishable,
|
|
}, nil
|
|
}
|
|
|
|
// Base may have already authenticated the request.
|
|
if e.Auth != nil {
|
|
return &Identity{UserID: e.Auth.Id, Email: e.Auth.GetString("email")}, nil
|
|
}
|
|
|
|
cred := apis.Credential(e.Request)
|
|
if cred == "" {
|
|
return nil, e.UnauthorizedError("Authentication required. Use Authorization: Bearer <token> or X-API-Key.", nil)
|
|
}
|
|
|
|
switch auth.ClassifyCredential(cred) {
|
|
case auth.KeyJWT:
|
|
u, err := p.iam.ValidateToken(cred)
|
|
if err != nil {
|
|
return nil, e.UnauthorizedError("invalid or expired token", err)
|
|
}
|
|
return p.identityFromIAM(e, u, false)
|
|
case auth.KeyIAMPublishable:
|
|
u, err := p.iam.ResolveAPIKey(cred)
|
|
if err != nil {
|
|
return nil, e.UnauthorizedError("invalid API key", err)
|
|
}
|
|
return p.identityFromIAM(e, u, true)
|
|
case auth.KeyIAMSecret, auth.KeyIAMService:
|
|
u, err := p.iam.ResolveAPIKey(cred)
|
|
if err != nil {
|
|
return nil, e.UnauthorizedError("invalid API key", err)
|
|
}
|
|
return p.identityFromIAM(e, u, false)
|
|
default:
|
|
return nil, e.UnauthorizedError("unsupported credential — bootnode keys identify a project, not a user", nil)
|
|
}
|
|
}
|
|
|
|
// identityFromIAM builds an Identity from a resolved IAM user, enforcing the
|
|
// org allow-list.
|
|
func (p *plugin) identityFromIAM(e *core.RequestEvent, u *iam.User, readOnly bool) (*Identity, error) {
|
|
org := ""
|
|
if len(u.OrgIDs) > 0 {
|
|
org = u.OrgIDs[0]
|
|
}
|
|
if org != "" && !p.config.orgAllowed(org) {
|
|
return nil, e.ForbiddenError("organization '"+org+"' is not allowed", nil)
|
|
}
|
|
return &Identity{UserID: u.ID, Email: u.Email, Org: org, ReadOnly: readOnly}, nil
|
|
}
|
|
|
|
// orgLabels returns the standard CR labels scoping a resource to an org.
|
|
func orgLabels(org string) map[string]string {
|
|
return map[string]string{
|
|
"app.kubernetes.io/managed-by": "bootnode",
|
|
"bootnode.dev/org": org,
|
|
}
|
|
}
|