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>
96 lines
3.1 KiB
Go
96 lines
3.1 KiB
Go
// Package workers ports the bootnode background workers. It currently provides
|
|
// webhook delivery (the Go equivalent of bootnode/workers/webhook.py): sign a
|
|
// payload with the webhook's HMAC secret, POST it, and return the delivery
|
|
// outcome for the caller to persist.
|
|
//
|
|
// This is a library, not a daemon. Base does not run arq; delivery is driven by
|
|
// the plugin (synchronously on the event, or from a Base task/scheduler). The
|
|
// dispatch logic — signing, timeout, retry accounting — lives here, decoupled
|
|
// from any queue.
|
|
package workers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// SignatureHeader is the header carrying the HMAC-SHA256 signature of the body.
|
|
const SignatureHeader = "X-Bootnode-Signature"
|
|
|
|
// Delivery is the outcome of a single webhook delivery attempt. The plugin
|
|
// persists it to the _bootnode_webhook_deliveries collection.
|
|
type Delivery struct {
|
|
StatusCode int
|
|
ResponseBody string
|
|
Success bool
|
|
Error string
|
|
}
|
|
|
|
// Dispatcher delivers signed webhook payloads.
|
|
type Dispatcher struct {
|
|
http *http.Client
|
|
maxBody int64
|
|
}
|
|
|
|
// NewDispatcher constructs a Dispatcher with the given per-request timeout.
|
|
// A zero timeout defaults to 30s (matching the Python webhook_timeout).
|
|
func NewDispatcher(timeout time.Duration) *Dispatcher {
|
|
if timeout == 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
return &Dispatcher{
|
|
http: &http.Client{Timeout: timeout},
|
|
maxBody: 64 << 10, // cap recorded response bodies at 64 KiB
|
|
}
|
|
}
|
|
|
|
// Sign returns the hex-encoded HMAC-SHA256 of payload under secret. Exposed so
|
|
// receivers (and tests) can verify deliveries.
|
|
func Sign(secret string, payload []byte) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(payload)
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// Deliver POSTs payload to url, signing it with secret. The returned Delivery
|
|
// records the outcome regardless of HTTP status — a non-2xx response is a
|
|
// delivery with Success=false, not a Go error. A Go error is returned only for
|
|
// payload marshaling failures (a programming error).
|
|
func (d *Dispatcher) Deliver(ctx context.Context, url, secret string, payload any) (*Delivery, error) {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("workers: marshal webhook payload: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
// A malformed URL is a delivery failure, not a transport error to retry
|
|
// on infrastructure grounds — record it as such.
|
|
return &Delivery{Success: false, Error: err.Error()}, nil
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "bootnode-webhooks/1")
|
|
req.Header.Set(SignatureHeader, Sign(secret, body))
|
|
|
|
resp, err := d.http.Do(req)
|
|
if err != nil {
|
|
return &Delivery{Success: false, Error: err.Error()}, nil
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, d.maxBody))
|
|
return &Delivery{
|
|
StatusCode: resp.StatusCode,
|
|
ResponseBody: string(respBody),
|
|
Success: resp.StatusCode >= 200 && resp.StatusCode < 300,
|
|
}, nil
|
|
}
|