Files
fd23832c90 feat(bootnode): Go port foundation as a Base plugin (5 modules end-to-end) (#15)
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>
2026-06-18 17:16:15 -07:00

156 lines
4.7 KiB
Go

package bootnode
import (
"net/http"
"strings"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/plugins/bootnode/kube"
)
// handleCreateKMSKey provisions a managed key by applying a bootno.de/v1
// KMSSecret custom resource. Ports the key-provisioning surface of
// bootnode/api/keys.
//
// CRITICAL: no plaintext key material ever touches this service. The KMSSecret
// CR only NAMES a secret and the KMS path it should be synced from; the
// bootno.de operator (with KMS credentials) materializes the secret into the
// cluster. The request body carries no private key, and the response never
// returns one.
func (p *plugin) handleCreateKMSKey(e *core.RequestEvent) error {
id, err := p.requireUser(e)
if err != nil {
return err
}
if id.ReadOnly {
return e.ForbiddenError("publishable keys are read-only", nil)
}
if !p.kube.Available() {
return e.JSON(http.StatusServiceUnavailable, map[string]any{
"error": "no Kubernetes cluster configured for key provisioning",
})
}
var body struct {
Name string `json:"name"`
Chain string `json:"chain"`
KMSPath string `json:"kmsPath"`
KeyType string `json:"keyType"` // secp256k1 | ed25519 | bls
}
if err := e.BindBody(&body); err != nil {
return e.BadRequestError("invalid request body", err)
}
// Defensive: reject any attempt to smuggle key material through this API.
if rejectPlaintextKeyFields(e) {
return e.BadRequestError("private key material must never be sent — keys are sourced from KMS by path", nil)
}
body.Name = strings.TrimSpace(strings.ToLower(body.Name))
if !isCRName(body.Name) {
return e.BadRequestError("name must be a lowercase DNS-1123 label", nil)
}
if body.KMSPath == "" {
return e.BadRequestError("kmsPath is required (the KMS path to sync the secret from)", nil)
}
keyType := body.KeyType
if keyType == "" {
keyType = "secp256k1"
}
spec := map[string]any{
// kmsPath is a reference, not a secret. The operator reads it with its
// own KMS credentials and syncs the material into a k8s Secret.
"kmsPath": body.KMSPath,
"chain": strings.ToLower(body.Chain),
"keyType": keyType,
"createdBy": id.UserID,
"org": id.Org,
}
if _, err := p.kube.Apply(e.Request.Context(), kube.KMSSecretGVR, body.Name, orgLabels(id.Org), spec); err != nil {
return e.InternalServerError("failed to apply KMSSecret resource", err)
}
return e.JSON(http.StatusCreated, map[string]any{
"id": body.Name,
"name": body.Name,
"chain": spec["chain"],
"keyType": keyType,
"status": "syncing",
// No key material in the response — by design.
})
}
// handleGetKMSKey returns a KMSSecret CR's status (never its material). Ports
// the key-read surface of bootnode/api/keys.
func (p *plugin) handleGetKMSKey(e *core.RequestEvent) error {
if _, err := p.requireUser(e); err != nil {
return err
}
name := e.Request.PathValue("name")
if !p.kube.Available() {
return e.NotFoundError("key not found", nil)
}
var obj map[string]any
found, err := p.kube.Get(e.Request.Context(), kube.KMSSecretGVR, name, &obj)
if err != nil {
return e.InternalServerError("failed to fetch key", err)
}
if !found {
return e.NotFoundError("key not found", nil)
}
// Return only metadata + status, never spec material references in full.
return e.JSON(http.StatusOK, crSummary(obj))
}
// handleKeyStatus reports whether the caller's project has a managed key
// configured. Ports GET /keys/status from bootnode/api/keys/__init__.py, which
// the staking UI calls to decide whether an MPC wallet exists.
func (p *plugin) handleKeyStatus(e *core.RequestEvent) error {
id, err := p.requireUser(e)
if err != nil {
return err
}
configured := false
if p.kube.Available() {
var list struct {
Items []map[string]any `json:"items"`
}
if found, err := p.kube.Get(e.Request.Context(), kube.KMSSecretGVR, "", &list); err == nil && found {
for _, item := range list.Items {
if spec, _ := item["spec"].(map[string]any); spec != nil {
if id.Org == "" || spec["org"] == id.Org {
configured = true
break
}
}
}
}
}
return e.JSON(http.StatusOK, map[string]any{
"configured": configured,
"mpcEnabled": true,
"tfheEnabled": true,
})
}
// rejectPlaintextKeyFields returns true if the raw request body contains any
// field name that looks like it carries private key material. A defense in
// depth against a misbehaving client: this API only accepts KMS path
// references, never secrets.
func rejectPlaintextKeyFields(e *core.RequestEvent) bool {
info, err := e.RequestInfo()
if err != nil || info == nil {
return false
}
for k := range info.Body {
lk := strings.ToLower(k)
switch lk {
case "privatekey", "private_key", "secret", "secretkey", "secret_key", "mnemonic", "seed", "privkey":
return true
}
}
return false
}