// 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 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, } }