mirror of
https://github.com/hanzoai/base.git
synced 2026-08-06 09:32:29 +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>
127 lines
4.0 KiB
Go
127 lines
4.0 KiB
Go
package bootnode
|
|
|
|
import (
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// Config is the bootnode plugin configuration. It ports the environment-driven
|
|
// idioms of the Python bootnode/config.py (pydantic-settings) without the
|
|
// commodity-chain RPC catalogue — those URLs belong in the chain registry, not
|
|
// plugin config. Only what must vary between environments lives here.
|
|
//
|
|
// IAM and KMS endpoints are intentionally shared with the platform plugin: a
|
|
// bootnode deployment is always co-resident with platform, so it reuses the
|
|
// same IAM client surface (github.com/hanzoai/base/iam).
|
|
type Config struct {
|
|
// Enabled gates the whole plugin. A zero-value Config is disabled; callers
|
|
// opt in explicitly. Mirrors the waitlist plugin convention.
|
|
Enabled bool
|
|
|
|
// IAMEndpoint is the Hanzo IAM base URL (default https://hanzo.id).
|
|
IAMEndpoint string
|
|
|
|
// IAMClientID / IAMClientSecret are the bootnode service's IAM application
|
|
// credentials, used for the OAuth2 authorization-code exchange.
|
|
IAMClientID string
|
|
IAMClientSecret string
|
|
|
|
// AllowedOrgs restricts which IAM orgs may authenticate. Empty means the
|
|
// canonical four (hanzo, zoo, lux, pars).
|
|
AllowedOrgs []string
|
|
|
|
// FrontendURL is the default OAuth redirect base when a request omits an
|
|
// explicit redirect_uri (default http://localhost:3001).
|
|
FrontendURL string
|
|
|
|
// APIKeySalt is mixed into the SHA-256 of bootnode-issued API keys (bn_…)
|
|
// before storage. Required in production; the plugin refuses to start with
|
|
// the insecure default when IAMEndpoint points at a non-local host.
|
|
APIKeySalt string
|
|
|
|
// KubeNamespace is the namespace bootno.de CRs are applied into
|
|
// (default "bootnode").
|
|
KubeNamespace string
|
|
|
|
// CommerceURL / CommerceAPIKey configure the Hanzo Commerce billing client.
|
|
// An empty API key disables billing (no-op).
|
|
CommerceURL string
|
|
CommerceAPIKey string
|
|
}
|
|
|
|
const insecureSaltDefault = "change-me-in-production"
|
|
|
|
// canonicalOrgs is the default allow-list of IAM orgs.
|
|
var canonicalOrgs = []string{"hanzo", "zoo", "lux", "pars"}
|
|
|
|
// resolve fills defaults. It is idempotent.
|
|
func (c *Config) resolve() {
|
|
if c.IAMEndpoint == "" {
|
|
c.IAMEndpoint = "https://hanzo.id"
|
|
}
|
|
if c.FrontendURL == "" {
|
|
c.FrontendURL = "http://localhost:3001"
|
|
}
|
|
if c.KubeNamespace == "" {
|
|
c.KubeNamespace = "bootnode"
|
|
}
|
|
if c.CommerceURL == "" {
|
|
c.CommerceURL = "https://commerce.hanzo.ai"
|
|
}
|
|
if c.APIKeySalt == "" {
|
|
c.APIKeySalt = insecureSaltDefault
|
|
}
|
|
if len(c.AllowedOrgs) == 0 {
|
|
c.AllowedOrgs = canonicalOrgs
|
|
}
|
|
}
|
|
|
|
// orgAllowed reports whether org is in the allow-list.
|
|
func (c *Config) orgAllowed(org string) bool {
|
|
for _, o := range c.AllowedOrgs {
|
|
if o == org {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isProductionIAM reports whether the IAM endpoint is a non-local host, which
|
|
// the plugin treats as "production" for fail-fast secret validation.
|
|
func (c *Config) isProductionIAM() bool {
|
|
e := strings.ToLower(c.IAMEndpoint)
|
|
return !(strings.Contains(e, "localhost") || strings.Contains(e, "127.0.0.1"))
|
|
}
|
|
|
|
// ConfigFromEnv builds a Config from the standard bootnode environment
|
|
// variables. Used by the default Base wiring; tests construct Config directly.
|
|
func ConfigFromEnv() Config {
|
|
return Config{
|
|
Enabled: os.Getenv("BOOTNODE_ENABLED") == "true",
|
|
IAMEndpoint: os.Getenv("IAM_URL"),
|
|
IAMClientID: os.Getenv("IAM_CLIENT_ID"),
|
|
IAMClientSecret: os.Getenv("IAM_CLIENT_SECRET"),
|
|
FrontendURL: os.Getenv("FRONTEND_URL"),
|
|
APIKeySalt: os.Getenv("BOOTNODE_API_KEY_SALT"),
|
|
KubeNamespace: os.Getenv("BOOTNODE_K8S_NAMESPACE"),
|
|
CommerceURL: os.Getenv("COMMERCE_URL"),
|
|
CommerceAPIKey: os.Getenv("COMMERCE_API_KEY"),
|
|
AllowedOrgs: splitNonEmpty(os.Getenv("BOOTNODE_ALLOWED_ORGS")),
|
|
}
|
|
}
|
|
|
|
// splitNonEmpty splits a comma-separated env value, trimming blanks.
|
|
func splitNonEmpty(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
out := parts[:0]
|
|
for _, p := range parts {
|
|
if t := strings.TrimSpace(p); t != "" {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|