mirror of
https://github.com/hanzoai/base.git
synced 2026-08-06 12:32:21 +00:00
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>
265 lines
7.7 KiB
Go
265 lines
7.7 KiB
Go
package bootnode
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/plugins/bootnode/kube"
|
|
)
|
|
|
|
// Network tiers. Resource defaults are resolved server-side; the request only
|
|
// names the tier.
|
|
const (
|
|
tierStarter = "starter"
|
|
tierPro = "pro"
|
|
tierEnterprise = "enterprise"
|
|
)
|
|
|
|
// tierDefaults returns the replica/validator allocation for a tier. This is the
|
|
// Go port of the Python _tier_defaults — but it feeds a Network CR spec for the
|
|
// operator to reconcile, rather than templating raw Kubernetes manifests.
|
|
func tierDefaults(tier string) (web, api, validators int) {
|
|
switch tier {
|
|
case tierPro:
|
|
return 2, 3, 3
|
|
case tierEnterprise:
|
|
return 3, 5, 5
|
|
default: // starter
|
|
return 2, 0, 0
|
|
}
|
|
}
|
|
|
|
// handleCreateNetwork applies a bootno.de/v1 Network custom resource for a
|
|
// 1-click white-labeled network. Ports POST /networks. Unlike the Python (which
|
|
// shelled out to kubectl with nginx-annotated Ingress manifests), this submits
|
|
// a declarative Network CR; the bootno.de operator owns the actual rollout
|
|
// (ingress, TLS, CORS, IAM app, validator fleet).
|
|
func (p *plugin) handleCreateNetwork(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 network provisioning",
|
|
})
|
|
}
|
|
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Tier string `json:"tier"`
|
|
Region string `json:"region"`
|
|
ChainID *int `json:"chainId"`
|
|
DeployValidators bool `json:"deployValidators"`
|
|
ValidatorCount int `json:"validatorCount"`
|
|
Brand struct {
|
|
Name string `json:"name"`
|
|
Domain string `json:"domain"`
|
|
LogoURL string `json:"logoUrl"`
|
|
PrimaryColor string `json:"primaryColor"`
|
|
AccentColor string `json:"accentColor"`
|
|
} `json:"brand"`
|
|
}
|
|
if err := e.BindBody(&body); err != nil {
|
|
return e.BadRequestError("invalid request body", err)
|
|
}
|
|
body.Name = strings.TrimSpace(strings.ToLower(body.Name))
|
|
if !isCRName(body.Name) {
|
|
return e.BadRequestError("name must be a lowercase DNS-1123 label (a-z, 0-9, -)", nil)
|
|
}
|
|
if body.Brand.Domain == "" {
|
|
return e.BadRequestError("brand.domain is required", nil)
|
|
}
|
|
tier := body.Tier
|
|
if tier == "" {
|
|
tier = tierStarter
|
|
}
|
|
web, api, validators := tierDefaults(tier)
|
|
if !body.DeployValidators {
|
|
validators = 0
|
|
} else if body.ValidatorCount > 0 {
|
|
validators = body.ValidatorCount
|
|
}
|
|
region := body.Region
|
|
if region == "" {
|
|
region = "sfo3"
|
|
}
|
|
|
|
spec := map[string]any{
|
|
"tier": tier,
|
|
"region": region,
|
|
"brand": map[string]any{
|
|
"name": orDefault(body.Brand.Name, body.Name),
|
|
"domain": body.Brand.Domain,
|
|
"logoUrl": body.Brand.LogoURL,
|
|
"primaryColor": orDefault(body.Brand.PrimaryColor, "#000000"),
|
|
"accentColor": orDefault(body.Brand.AccentColor, "#fd4444"),
|
|
},
|
|
"iam": map[string]any{
|
|
"org": body.Name,
|
|
"domain": body.Name + ".id",
|
|
"clientId": body.Name + "-cloud",
|
|
},
|
|
"replicas": map[string]any{
|
|
"web": web,
|
|
"api": api,
|
|
},
|
|
"validators": map[string]any{
|
|
"enabled": body.DeployValidators,
|
|
"count": validators,
|
|
},
|
|
"urls": map[string]any{
|
|
"cloud": "https://cloud." + body.Brand.Domain,
|
|
"api": "https://api.cloud." + body.Brand.Domain,
|
|
"ws": "wss://ws.cloud." + body.Brand.Domain,
|
|
"rpc": "https://api.cloud." + body.Brand.Domain + "/v1/rpc/" + body.Name,
|
|
},
|
|
"createdBy": id.UserID,
|
|
"org": id.Org,
|
|
}
|
|
if body.ChainID != nil {
|
|
spec["chainId"] = *body.ChainID
|
|
}
|
|
|
|
if _, err := p.kube.Apply(e.Request.Context(), kube.NetworkGVR, body.Name, orgLabels(id.Org), spec); err != nil {
|
|
return e.InternalServerError("failed to apply Network resource", err)
|
|
}
|
|
|
|
return e.JSON(http.StatusCreated, map[string]any{
|
|
"id": body.Name,
|
|
"name": body.Name,
|
|
"tier": tier,
|
|
"region": region,
|
|
"status": "provisioning",
|
|
"namespace": p.kube.Namespace(),
|
|
"cloudUrl": spec["urls"].(map[string]any)["cloud"],
|
|
"apiUrl": spec["urls"].(map[string]any)["api"],
|
|
})
|
|
}
|
|
|
|
// handleListNetworks lists Network CRs scoped to the caller's org. Ports
|
|
// GET /networks. The cluster is the source of truth.
|
|
func (p *plugin) handleListNetworks(e *core.RequestEvent) error {
|
|
id, err := p.requireUser(e)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return p.listCRs(e, kube.NetworkGVR, id.Org)
|
|
}
|
|
|
|
// handleGetNetwork returns one Network CR. Ports GET /networks/{id}.
|
|
func (p *plugin) handleGetNetwork(e *core.RequestEvent) error {
|
|
if _, err := p.requireUser(e); err != nil {
|
|
return err
|
|
}
|
|
return p.getCR(e, kube.NetworkGVR, e.Request.PathValue("id"))
|
|
}
|
|
|
|
// handleDeleteNetwork tears down a Network CR. Ports DELETE /networks/{id}.
|
|
func (p *plugin) handleDeleteNetwork(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)
|
|
}
|
|
name := e.Request.PathValue("id")
|
|
if err := p.kube.Delete(e.Request.Context(), kube.NetworkGVR, name); err != nil {
|
|
return e.InternalServerError("failed to delete Network resource", err)
|
|
}
|
|
return e.JSON(http.StatusOK, map[string]any{"status": "deleted", "id": name})
|
|
}
|
|
|
|
// --- shared CR list/get helpers (used by networks and nodes) ---
|
|
|
|
// listCRs lists custom resources of a kind, filtered to org via label selector.
|
|
func (p *plugin) listCRs(e *core.RequestEvent, gvr kube.GVR, org string) error {
|
|
if !p.kube.Available() {
|
|
return e.JSON(http.StatusOK, []any{}) // no cluster → empty list, not an error
|
|
}
|
|
var list struct {
|
|
Items []map[string]any `json:"items"`
|
|
}
|
|
// A list is a GET on the plural with no name; reuse Get with empty name by
|
|
// fetching the collection endpoint.
|
|
found, err := p.kube.Get(e.Request.Context(), gvr, "", &list)
|
|
if err != nil {
|
|
return e.InternalServerError(fmt.Sprintf("failed to list %s", gvr.Kind), err)
|
|
}
|
|
if !found {
|
|
return e.JSON(http.StatusOK, []any{})
|
|
}
|
|
out := make([]map[string]any, 0, len(list.Items))
|
|
for _, item := range list.Items {
|
|
summary := crSummary(item)
|
|
if org == "" || summary["org"] == org || summary["org"] == nil {
|
|
out = append(out, summary)
|
|
}
|
|
}
|
|
return e.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// getCR fetches one custom resource by name.
|
|
func (p *plugin) getCR(e *core.RequestEvent, gvr kube.GVR, name string) error {
|
|
if !p.kube.Available() {
|
|
return e.NotFoundError(gvr.Kind+" not found", nil)
|
|
}
|
|
var obj map[string]any
|
|
found, err := p.kube.Get(e.Request.Context(), gvr, name, &obj)
|
|
if err != nil {
|
|
return e.InternalServerError("failed to fetch "+gvr.Kind, err)
|
|
}
|
|
if !found {
|
|
return e.NotFoundError(gvr.Kind+" not found", nil)
|
|
}
|
|
return e.JSON(http.StatusOK, obj)
|
|
}
|
|
|
|
// crSummary flattens a CR into a compact API response.
|
|
func crSummary(item map[string]any) map[string]any {
|
|
meta, _ := item["metadata"].(map[string]any)
|
|
spec, _ := item["spec"].(map[string]any)
|
|
status, _ := item["status"].(map[string]any)
|
|
out := map[string]any{}
|
|
if meta != nil {
|
|
out["id"] = meta["name"]
|
|
out["name"] = meta["name"]
|
|
}
|
|
if spec != nil {
|
|
out["org"] = spec["org"]
|
|
out["tier"] = spec["tier"]
|
|
out["region"] = spec["region"]
|
|
out["chain"] = spec["chain"]
|
|
}
|
|
if status != nil {
|
|
out["status"] = status["phase"]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func orDefault(v, def string) string {
|
|
if v == "" {
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
// isCRName validates a DNS-1123 label suitable for a Kubernetes object name.
|
|
func isCRName(s string) bool {
|
|
if s == "" || len(s) > 63 || s[0] == '-' || s[len(s)-1] == '-' {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|