Why: consolidate the Python bootnode backend (bootnode/api/, ~100 .py files)
onto Hanzo Base, leveraging Base's IAM client and per-org/per-user tenant
infrastructure instead of reimplementing auth, sessions, and multi-tenancy.
This is the structural foundation + the 5 most-important modules, not the whole
port.
What landed
- plugins/bootnode/: blockchain developer platform mounted under /v1.
Five modules ported end-to-end:
1. auth — multi-network OAuth2 callback (lux/pars/zoo/hanzo share the
lux-web3 IAM app; client id derived from redirect_uri) + bn_
project API keys (salted SHA-256, raw key shown once, verified
in constant time). Accepts IAM JWTs and pk-/sk-/hk- keys by
reusing github.com/hanzoai/base/iam — no IAM logic duplicated.
2. team — org/member CRUD scoped to the caller's project; invited emails
resolved against IAM (active) or held pending with an invite
token.
3. networks — applies bootno.de/v1 Network CRs (white-label brand, tier,
region, validator fleet). Replaces the Python's kubectl + raw
nginx-Ingress templating with a declarative CR for the
bootno.de operator to reconcile.
4. nodes — applies bootno.de/v1 NodeFleet CRs (CRD-driven cloud path;
the Python docker provider was a local-dev concern).
5. keys — applies bootno.de/v1 KMSSecret CRs by KMS path. NO plaintext
key material ever touches this service; the request and
response carry none, and a guard rejects any private-key field.
- plugins/bootnode/kube/: dependency-free Kubernetes REST client (net/http
server-side apply). No client-go, no CGO. In-cluster SA or KUBE_APISERVER.
- plugins/commerce/: typed Hanzo Commerce (Square billing) client behind a
Client interface. bootnode depends on the interface; commerce never depends
on bootnode/iam.
- 11 SQLAlchemy models -> Base collections (models/collections.go). No `users`
collection: IAM owns identity; bootnode references IAM user ids as text.
OrgCluster is the canonical org->k8s-cluster mapping.
- examples/base/main.go: platform plugin now runs PrincipalIsolation="sqlite"
(per-org + per-user encrypted SQLite); bootnode registered (BOOTNODE_ENABLED).
Modules pending (15, tracked in PR body): chat, zap, billing-http, bundler,
fleets, gas, infra, launch, lux, mpc, nfts, observability, rpc, tokens,
transfers, wallets, webhooks-http. (chains is also ported as a bonus 6th.)
Tests (20 functions, all green incl -race)
- auth: token classification, key gen/hash/verify (tamper + wrong-salt),
redirect->clientId derivation.
- kube: server-side-apply shape (PATCH + apply-patch+yaml + fieldManager),
error propagation, 404 get/delete idempotency.
- commerce: disabled no-op, get-or-create (create + existing), usage error
propagation, immediate cancel.
- workers: HMAC-signed delivery, non-2xx-is-failure, unreachable-is-failure.
- bootnode: full end-to-end against a fake IAM + fake apiserver — /me 401 then
200, project + bn_ key, team invite/list, Network + NodeFleet CR apply/get,
KMSSecret plaintext-rejection + apply + status, public chains. Plus
fail-fast on the insecure default salt against production IAM.
Verified: `go build ./...` exits 0; `go test -race ./plugins/bootnode/...
./plugins/commerce/...` passes; live binary serves /v1/chains (200) and gates
/v1/auth/me, /v1/networks (401); per-org SQLite isolation logged active; all
10 _bootnode_ collections created on boot.
Co-authored-by: zeekay <z@zeekay.io>
239 lines
7.8 KiB
Go
239 lines
7.8 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 + tenant 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 (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"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.
|
|
func (p *plugin) registerRoutes(r *router.Router[*core.RequestEvent]) {
|
|
g := r.Group("/v1")
|
|
|
|
// auth/
|
|
g.POST("/auth/oauth/callback", p.handleOAuthCallback)
|
|
g.GET("/auth/me", p.handleMe)
|
|
g.POST("/auth/projects", p.handleCreateProject)
|
|
g.GET("/auth/projects/{id}", p.handleGetProject)
|
|
g.POST("/auth/keys", p.handleCreateAPIKey)
|
|
g.GET("/auth/keys", p.handleListAPIKeys)
|
|
g.DELETE("/auth/keys/{id}", p.handleDeleteAPIKey)
|
|
|
|
// team/
|
|
g.GET("/team", p.handleListTeam)
|
|
g.POST("/team", p.handleInviteMember)
|
|
g.PATCH("/team/{id}", p.handleUpdateMember)
|
|
g.DELETE("/team/{id}", p.handleRemoveMember)
|
|
|
|
// chains/
|
|
g.GET("/chains", p.handleListChains)
|
|
g.GET("/chains/{chain}", p.handleGetChain)
|
|
|
|
// networks/ (bootno.de/v1 Network CRs)
|
|
g.POST("/networks", p.handleCreateNetwork)
|
|
g.GET("/networks", p.handleListNetworks)
|
|
g.GET("/networks/{id}", p.handleGetNetwork)
|
|
g.DELETE("/networks/{id}", p.handleDeleteNetwork)
|
|
|
|
// nodes/ (bootno.de/v1 NodeFleet CRs)
|
|
g.POST("/nodes", p.handleCreateNodeFleet)
|
|
g.GET("/nodes", p.handleListNodeFleets)
|
|
g.GET("/nodes/{id}", p.handleGetNodeFleet)
|
|
g.DELETE("/nodes/{id}", p.handleDeleteNodeFleet)
|
|
|
|
// keys/ (bootno.de/v1 KMSSecret CRs — no plaintext storage)
|
|
g.POST("/keys", p.handleCreateKMSKey)
|
|
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.
|
|
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"),
|
|
}, 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, isBearer := bearerToken(e.Request)
|
|
if cred == "" {
|
|
return nil, e.UnauthorizedError("Authentication required. Use Authorization: Bearer <token> or X-API-Key.", nil)
|
|
}
|
|
|
|
switch auth.ClassifyCredential(cred, isBearer) {
|
|
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
|
|
}
|
|
|
|
// bearerToken extracts a credential from the Authorization (Bearer) or
|
|
// X-API-Key header. The bool reports whether it came from a Bearer header.
|
|
func bearerToken(r *http.Request) (string, bool) {
|
|
if k := r.Header.Get("X-API-Key"); k != "" {
|
|
return k, false
|
|
}
|
|
authz := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(authz, "Bearer ") {
|
|
return strings.TrimPrefix(authz, "Bearer "), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|