diff --git a/.gitignore b/.gitignore index 087ab11..1fd070d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ Thumbs.db # === Core (Go) === core/phantom-auth/ +bin/ core/bin/ core/bin-linux/ core/dist/ @@ -89,3 +90,6 @@ os/output/ .dev/ .local/ local/ + +# Implementation plans (not committed) +core/plans/ diff --git a/core/Makefile b/core/Makefile index da8ab1a..9d7131b 100644 --- a/core/Makefile +++ b/core/Makefile @@ -63,7 +63,7 @@ test-e2e-quick: .PHONY: build clean test deps tidy fmt vet lint install-hooks push-devnet push-testnet rollout-devnet rollout-testnet release -VERSION := 0.120.0 +VERSION := 0.122.0 COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' -X 'main.date=$(DATE)' @@ -80,6 +80,7 @@ build: deps go build -ldflags "$(LDFLAGS) -X 'github.com/DeBrosOfficial/network/pkg/gateway.BuildVersion=$(VERSION)' -X 'github.com/DeBrosOfficial/network/pkg/gateway.BuildCommit=$(COMMIT)' -X 'github.com/DeBrosOfficial/network/pkg/gateway.BuildTime=$(DATE)'" -o bin/gateway ./cmd/gateway go build -ldflags "$(LDFLAGS)" -o bin/sfu ./cmd/sfu go build -ldflags "$(LDFLAGS)" -o bin/turn ./cmd/turn + go build -ldflags "$(LDFLAGS)" -o bin/orama-sni-router ./cmd/sni-router @echo "Build complete! Run ./bin/orama version" # Cross-compile CLI for Linux (only binary needed locally; VPS builds everything else from source) diff --git a/core/cmd/cli/root.go b/core/cmd/cli/root.go index 0f27fdd..8401311 100644 --- a/core/cmd/cli/root.go +++ b/core/cmd/cli/root.go @@ -18,7 +18,12 @@ import ( "github.com/DeBrosOfficial/network/pkg/cli/cmd/monitorcmd" "github.com/DeBrosOfficial/network/pkg/cli/cmd/namespacecmd" "github.com/DeBrosOfficial/network/pkg/cli/cmd/node" + "github.com/DeBrosOfficial/network/pkg/cli/cmd/nodescmd" + "github.com/DeBrosOfficial/network/pkg/cli/cmd/pushcmd" + "github.com/DeBrosOfficial/network/pkg/cli/cmd/rolloutcmd" "github.com/DeBrosOfficial/network/pkg/cli/cmd/sandboxcmd" + "github.com/DeBrosOfficial/network/pkg/cli/cmd/sshcmd" + "github.com/DeBrosOfficial/network/pkg/cli/cmd/statuscmd" ) // version metadata populated via -ldflags at build time @@ -91,6 +96,13 @@ and interacting with the Orama distributed network.`, // Sandbox command (ephemeral Hetzner Cloud clusters) rootCmd.AddCommand(sandboxcmd.Cmd) + // Unified node management commands + rootCmd.AddCommand(nodescmd.Cmd) + rootCmd.AddCommand(pushcmd.Cmd) + rootCmd.AddCommand(rolloutcmd.Cmd) + rootCmd.AddCommand(statuscmd.Cmd) + rootCmd.AddCommand(sshcmd.Cmd) + return rootCmd } diff --git a/core/cmd/sni-router/main.go b/core/cmd/sni-router/main.go new file mode 100644 index 0000000..cc727df --- /dev/null +++ b/core/cmd/sni-router/main.go @@ -0,0 +1,242 @@ +// Command sni-router is a TLS-level Server Name Indication router. +// +// It listens on a public TCP port (typically :443), peeks at the TLS +// ClientHello SNI on each connection, and forwards the raw stream to +// a configured backend. It does NOT terminate TLS — encrypted bytes +// pass through verbatim. This lets one port serve multiple TLS-speaking +// backends (HTTPS for the gateway, TURN-over-TLS for stealth WebRTC). +// +// See pkg/sniproxy for the underlying library. +// +// Configuration: YAML file at --config (defaults to ~/.orama/sni-router.yaml). +// +// Example sni-router.yaml: +// +// listen: ":443" +// client_hello_timeout: 5s +// backend_dial_timeout: 5s +// max_concurrent_conns: 10000 +// fallback: +// name: caddy +// addr: "127.0.0.1:8443" +// routes: +// - match: "cdn.example.com" +// backend: +// name: turn-tls +// addr: "127.0.0.1:5349" +// - match: "turn.example.com" +// backend: +// name: turn-tls +// addr: "127.0.0.1:5349" +// - match: "*.ns-myapp.example.com" +// backend: +// name: gateway +// addr: "127.0.0.1:8443" +package main + +import ( + "flag" + "fmt" + "net" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/DeBrosOfficial/network/pkg/config" + "github.com/DeBrosOfficial/network/pkg/logging" + "github.com/DeBrosOfficial/network/pkg/sniproxy" + "go.uber.org/zap" +) + +var ( + version = "dev" + commit = "unknown" +) + +// yamlBackend mirrors sniproxy.Backend for YAML decoding. +type yamlBackend struct { + Name string `yaml:"name"` + Network string `yaml:"network"` + Addr string `yaml:"addr"` +} + +// yamlRoute mirrors sniproxy.Route for YAML decoding. +type yamlRoute struct { + Match string `yaml:"match"` + Backend yamlBackend `yaml:"backend"` +} + +// yamlConfig is the on-disk configuration shape. +type yamlConfig struct { + Listen string `yaml:"listen"` + ClientHelloTimeout time.Duration `yaml:"client_hello_timeout"` + BackendDialTimeout time.Duration `yaml:"backend_dial_timeout"` + MaxConcurrentConns int `yaml:"max_concurrent_conns"` + Fallback yamlBackend `yaml:"fallback"` + Routes []yamlRoute `yaml:"routes"` +} + +func main() { + logger, err := logging.NewColoredLogger(logging.ComponentSNI, true) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to init logger: %v\n", err) + os.Exit(1) + } + + logger.ComponentInfo(logging.ComponentSNI, "Starting SNI router", + zap.String("version", version), + zap.String("commit", commit)) + + cfg := parseConfig(logger) + + router := sniproxy.NewRouter(toBackend(cfg.Fallback)) + router.Replace(toRoutes(cfg.Routes), toBackend(cfg.Fallback)) + + srv := sniproxy.NewServer(router, sniproxy.Config{ + ClientHelloTimeout: cfg.ClientHelloTimeout, + BackendDialTimeout: cfg.BackendDialTimeout, + MaxConcurrentConns: cfg.MaxConcurrentConns, + }, logger.Logger) + + ln, err := net.Listen("tcp", cfg.Listen) + if err != nil { + logger.ComponentError(logging.ComponentSNI, "Failed to listen", + zap.String("addr", cfg.Listen), zap.Error(err)) + os.Exit(1) + } + + logger.ComponentInfo(logging.ComponentSNI, "SNI router listening", + zap.String("addr", cfg.Listen), + zap.Int("routes", len(cfg.Routes)), + zap.String("fallback", cfg.Fallback.Addr), + ) + + // Run Serve in a goroutine so the main goroutine can wait on signals. + serveErrCh := make(chan error, 1) + go func() { + serveErrCh <- srv.Serve(ln) + }() + + // Wait for termination signal or unrecoverable Serve error. + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + + select { + case sig := <-quit: + logger.ComponentInfo(logging.ComponentSNI, "Shutdown signal received", + zap.String("signal", sig.String())) + case err := <-serveErrCh: + logger.ComponentError(logging.ComponentSNI, "Serve returned", + zap.Error(err)) + } + + // Stop accepting new connections, then drain in-flight ones. + _ = ln.Close() + srv.Close() + + logger.ComponentInfo(logging.ComponentSNI, "SNI router shutdown complete") +} + +func parseConfig(logger *logging.ColoredLogger) yamlConfig { + configFlag := flag.String("config", "", "Config file path (absolute or filename in ~/.orama)") + flag.Parse() + + var configPath string + var err error + if *configFlag != "" { + if filepath.IsAbs(*configFlag) { + configPath = *configFlag + } else { + configPath, err = config.DefaultPath(*configFlag) + if err != nil { + logger.ComponentError(logging.ComponentSNI, "Failed to determine config path", + zap.Error(err)) + os.Exit(1) + } + } + } else { + configPath, err = config.DefaultPath("sni-router.yaml") + if err != nil { + logger.ComponentError(logging.ComponentSNI, "Failed to determine config path", + zap.Error(err)) + os.Exit(1) + } + } + + data, err := os.ReadFile(configPath) + if err != nil { + logger.ComponentError(logging.ComponentSNI, "Config file not found", + zap.String("path", configPath), zap.Error(err)) + fmt.Fprintf(os.Stderr, "\nConfig file not found at %s\n", configPath) + os.Exit(1) + } + + var y yamlConfig + if err := config.DecodeStrict(strings.NewReader(string(data)), &y); err != nil { + logger.ComponentError(logging.ComponentSNI, "Failed to parse SNI router config", + zap.Error(err)) + fmt.Fprintf(os.Stderr, "Configuration parse error: %v\n", err) + os.Exit(1) + } + + if errs := validateConfig(&y); len(errs) > 0 { + fmt.Fprintf(os.Stderr, "\nSNI router configuration errors (%d):\n", len(errs)) + for _, e := range errs { + fmt.Fprintf(os.Stderr, " - %s\n", e) + } + fmt.Fprintf(os.Stderr, "\nPlease fix the configuration and try again.\n") + os.Exit(1) + } + + logger.ComponentInfo(logging.ComponentSNI, "Loaded SNI router configuration", + zap.String("path", configPath), + ) + + return y +} + +// validateConfig returns a non-empty slice of human-readable errors on misconfig. +func validateConfig(y *yamlConfig) []string { + var errs []string + if y.Listen == "" { + errs = append(errs, "listen: required (e.g. \":443\")") + } + if y.Fallback.Addr == "" { + errs = append(errs, "fallback.addr: required (where to send unmatched SNIs, typically Caddy)") + } + for i, r := range y.Routes { + if r.Match == "" { + errs = append(errs, fmt.Sprintf("routes[%d].match: required", i)) + } + if r.Backend.Addr == "" { + errs = append(errs, fmt.Sprintf("routes[%d].backend.addr: required", i)) + } + } + return errs +} + +func toBackend(b yamlBackend) sniproxy.Backend { + network := b.Network + if network == "" { + network = "tcp" + } + return sniproxy.Backend{ + Name: b.Name, + Network: network, + Addr: b.Addr, + } +} + +func toRoutes(in []yamlRoute) []sniproxy.Route { + out := make([]sniproxy.Route, len(in)) + for i, r := range in { + out[i] = sniproxy.Route{ + Match: r.Match, + Backend: toBackend(r.Backend), + } + } + return out +} diff --git a/core/docs/STEALTH_TURN.md b/core/docs/STEALTH_TURN.md new file mode 100644 index 0000000..1005e89 --- /dev/null +++ b/core/docs/STEALTH_TURN.md @@ -0,0 +1,187 @@ +# Stealth TURN Deployment Guide + +## What this is + +A TLS-level SNI router that lets Orama serve TURN-over-TLS on `:443`, +sharing the port with Caddy HTTPS. From a network observer's +perspective, TURN traffic is indistinguishable from ordinary HTTPS — +useful for users in regions that block standard VoIP ports (UAE, Saudi +Arabia, China, Iran). + +## Architecture + +``` + Internet + │ + ▼ + TCP :443 + │ + ┌─────────┴─────────┐ + │ orama-sni-router │ peeks SNI, forwards bytes + └─────────┬─────────┘ + │ + ┌───────────────┼────────────────┐ + ▼ ▼ + cdn. *., + turn. (everything else) + │ │ + ▼ ▼ + Pion TURN-TLS Caddy + 127.0.0.1:5349 127.0.0.1:8443 + (existing) (moved from :443) +``` + +The router does **not** terminate TLS. It reads the unencrypted TLS +ClientHello (first ~5 KB), inspects the SNI extension, and dials the +matching backend. Encrypted bytes pass through verbatim. + +## Components + +- **Library:** `pkg/sniproxy/` — ClientHello parser, route table, TCP server +- **Binary:** `cmd/sni-router/` (built as `bin/orama-sni-router`) +- **Systemd unit:** `systemd/orama-sni-router.service` +- **Config:** `~/.orama/sni-router.yaml` + +## Deployment cutover + +⚠️ **This change touches production `:443`. Stage on one node first, watch for 24h, then roll out.** + +### 1. Reconfigure Caddy to listen on `:8443` + +Update wherever the Caddy config is generated (`pkg/environments/production/installers/caddy.go`) +so Caddy binds `:8443` (HTTPS) and `:8080` (HTTP) instead of `:443` and `:80`. + +Drop `CAP_NET_BIND_SERVICE` from Caddy's systemd unit — it no longer needs privileged ports. + +### 2. Provision the cert SAN for `cdn.` + +Caddy's automatic Let's Encrypt flow needs to issue a cert covering +`cdn.` and `cdn.ns-*.` so Pion TURN can read it +on startup. Add these names to Caddy's TLS config block. + +### 3. Drop `sni-router.yaml` config + +Example for a single-namespace node: + +```yaml +listen: ":443" +client_hello_timeout: 5s +backend_dial_timeout: 5s +max_concurrent_conns: 10000 +fallback: + name: caddy + addr: "127.0.0.1:8443" +routes: + - match: "cdn.example.com" + backend: + name: turn-tls + addr: "127.0.0.1:5349" + - match: "turn.example.com" + backend: + name: turn-tls + addr: "127.0.0.1:5349" +``` + +For multi-namespace, add per-namespace TURN backends (each namespace's +TURN-TLS port is allocated by `pkg/namespace`): + +```yaml + - match: "cdn.ns-myapp.example.com" + backend: { name: "turn-myapp", addr: "127.0.0.1:5349" } + - match: "cdn.ns-other.example.com" + backend: { name: "turn-other", addr: "127.0.0.1:5350" } +``` + +### 4. Deploy + start in order + +```bash +# Install binary +sudo cp bin-linux/orama-sni-router /opt/orama/bin/ + +# Install service +sudo cp systemd/orama-sni-router.service /etc/systemd/system/ +sudo systemctl daemon-reload + +# Stop Caddy briefly (it's about to lose :443) +sudo systemctl stop caddy + +# Start the SNI router (it takes :443) +sudo systemctl enable --now orama-sni-router + +# Restart Caddy on its new port +sudo systemctl start caddy + +# Verify +curl -v https://cdn.:443 # should hit TURN backend (TLS handshake will fail; that's fine) +curl -v https://:443 # should hit Caddy (normal HTTPS response) +``` + +### 5. Enable stealth in the gateway + +Once the SNI router is live, tell the gateway to advertise the stealth URI: + +```go +// in gateway dependencies / startup +webrtcHandlers.SetStealthCDNDomain("cdn.") +``` + +The credentials handler will start including `turns:cdn.:443` +in `POST /v1/webrtc/turn/credentials` responses automatically. + +### 6. Monitor + +```bash +journalctl -u orama-sni-router.service -f +journalctl -u caddy.service -f +``` + +Watch for: +- `Connection limit reached` warnings (bump `max_concurrent_conns`) +- `backend dial failed` warnings (Caddy isn't listening on `:8443`, or TURN isn't on `:5349`) +- `ClientHello peek failed` debugs (curious clients sending non-TLS to `:443` — usually port scanners) + +## Rollback + +If anything is wrong: + +```bash +sudo systemctl stop orama-sni-router +# Reconfigure Caddy back to :443 and restart +sudo systemctl restart caddy +``` + +Caddy reclaiming `:443` from the disabled router is the fastest way back to +the previous topology. + +## Known gaps + +- **Dynamic route source:** today's router reads YAML once at startup. To + pick up new namespaces without restart, implement a `RouteSource` that + polls `pkg/namespace` for active TURN deployments. The library is + already designed for `Router.Replace` to be called concurrently. +- **TLS cert hot-reload:** Pion TURN reads the cert once at startup. When + Caddy renews `cdn.`, Pion needs to be restarted to pick up + the new cert. A small file-watcher service (or a periodic restart in + off-peak hours) handles this for now. + +## What clients see + +Once enabled, the credentials response gains one entry: + +```json +{ + "username": "...", + "password": "...", + "ttl": 600, + "uris": [ + "turn:turn.example.com:3478?transport=udp", + "turn:turn.example.com:3478?transport=tcp", + "turns:turn.example.com:5349", + "turns:cdn.example.com:443" + ] +} +``` + +Browsers iterate ICE candidates; users in restricted regions will silently +succeed via the `:443` URI when others fail. No client-side change is +required. diff --git a/core/migrations/020_node_operators.sql b/core/migrations/020_node_operators.sql new file mode 100644 index 0000000..eb2343c --- /dev/null +++ b/core/migrations/020_node_operators.sql @@ -0,0 +1,14 @@ +-- Add operator wallet tracking to nodes. +-- operator_wallet links nodes to the wallet that provisioned them. + +ALTER TABLE dns_nodes ADD COLUMN operator_wallet TEXT; +ALTER TABLE dns_nodes ADD COLUMN environment TEXT DEFAULT 'production'; +ALTER TABLE dns_nodes ADD COLUMN ssh_user TEXT DEFAULT 'root'; +ALTER TABLE dns_nodes ADD COLUMN role TEXT DEFAULT 'node'; + +CREATE INDEX IF NOT EXISTS idx_dns_nodes_operator ON dns_nodes(operator_wallet); +CREATE INDEX IF NOT EXISTS idx_dns_nodes_environment ON dns_nodes(environment); + +ALTER TABLE wireguard_peers ADD COLUMN operator_wallet TEXT; + +ALTER TABLE invite_tokens ADD COLUMN operator_wallet TEXT; diff --git a/core/migrations/021_pubsub_trigger_patterns.sql b/core/migrations/021_pubsub_trigger_patterns.sql new file mode 100644 index 0000000..5b91e35 --- /dev/null +++ b/core/migrations/021_pubsub_trigger_patterns.sql @@ -0,0 +1,28 @@ +-- ============================================================================= +-- 021_pubsub_trigger_patterns.sql +-- +-- Add `topic_pattern` column alongside the existing `topic` column to +-- function_pubsub_triggers. The new column may contain SQLite GLOB +-- patterns (e.g. "presence:*") in addition to exact topic names. +-- +-- This is intentionally ADDITIVE rather than a column rename to remain +-- safe under rolling upgrades: +-- - Old binaries continue reading `topic` and keep working. +-- - New binaries read `topic_pattern` (which is back-filled from +-- `topic` for existing rows) and write BOTH columns. +-- A future migration can DROP COLUMN topic once every node is on the +-- new release. +-- ============================================================================= + +ALTER TABLE function_pubsub_triggers + ADD COLUMN topic_pattern TEXT NOT NULL DEFAULT ''; + +UPDATE function_pubsub_triggers +SET topic_pattern = topic +WHERE topic_pattern = ''; + +CREATE INDEX IF NOT EXISTS idx_function_pubsub_triggers_function + ON function_pubsub_triggers(function_id); + +CREATE INDEX IF NOT EXISTS idx_function_pubsub_triggers_enabled + ON function_pubsub_triggers(enabled); diff --git a/core/migrations/022_aggregation_windows.sql b/core/migrations/022_aggregation_windows.sql new file mode 100644 index 0000000..05f68f0 --- /dev/null +++ b/core/migrations/022_aggregation_windows.sql @@ -0,0 +1,20 @@ +-- ============================================================================= +-- 022_aggregation_windows.sql +-- +-- Add per-trigger aggregation parameters to function_pubsub_triggers. +-- +-- aggregation_window_ms = 0 means "no aggregation, invoke once per event" +-- (the existing behaviour). Any positive value enables buffering of events +-- in-memory on the dispatching node; the function is invoked once per +-- window with a batched payload. +-- +-- aggregation_max_batch_size caps the per-window batch. When the buffer +-- reaches this size, the dispatcher flushes immediately even if the +-- window timer hasn't fired yet. +-- ============================================================================= + +ALTER TABLE function_pubsub_triggers + ADD COLUMN aggregation_window_ms INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE function_pubsub_triggers + ADD COLUMN aggregation_max_batch_size INTEGER NOT NULL DEFAULT 100; diff --git a/core/migrations/023_push_devices.sql b/core/migrations/023_push_devices.sql new file mode 100644 index 0000000..bc6c908 --- /dev/null +++ b/core/migrations/023_push_devices.sql @@ -0,0 +1,33 @@ +-- ============================================================================= +-- 023_push_devices.sql +-- +-- Per-namespace, per-user push notification device registry. +-- +-- token_encrypted is AES-256-GCM ciphertext (prefix 'enc:') derived via +-- pkg/secrets. Tokens are sensitive — they let the holder spam a user's +-- device — so they are never returned via any API or written to logs. +-- +-- provider matches a registered push.PushProvider name: +-- 'ntfy', 'expo', 'apns', 'fcm' (future), ... +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS push_devices ( + id TEXT PRIMARY KEY, + namespace TEXT NOT NULL, + user_id TEXT NOT NULL, + device_id TEXT NOT NULL, + provider TEXT NOT NULL, + token_encrypted TEXT NOT NULL, + platform TEXT, + app_version TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_seen INTEGER, + UNIQUE(namespace, user_id, device_id) +); + +CREATE INDEX IF NOT EXISTS idx_push_devices_user + ON push_devices(namespace, user_id); + +CREATE INDEX IF NOT EXISTS idx_push_devices_provider + ON push_devices(provider); diff --git a/core/migrations/024_namespace_publish_seq.sql b/core/migrations/024_namespace_publish_seq.sql new file mode 100644 index 0000000..8aef559 --- /dev/null +++ b/core/migrations/024_namespace_publish_seq.sql @@ -0,0 +1,18 @@ +-- ============================================================================= +-- 024_namespace_publish_seq.sql +-- +-- Per-namespace monotonically-increasing sequence number assigned by +-- exec_and_publish (plan 08). The seq is included in the wake-up payload so +-- subscribers can detect "I'm behind, retry" gaps caused by cross-node +-- replication lag between the leader's commit and the gossipsub message. +-- +-- The row is upserted in the same atomic batch as the user's writes, so the +-- assigned seq exactly mirrors the commit number. See plan: +-- core/plans/platform/08_EXEC_AND_PUBLISH.md +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS namespace_publish_seq ( + namespace TEXT PRIMARY KEY, + next_seq BIGINT NOT NULL DEFAULT 1, + updated_at INTEGER NOT NULL +); diff --git a/core/migrations/025_persistent_ws.sql b/core/migrations/025_persistent_ws.sql new file mode 100644 index 0000000..225c543 --- /dev/null +++ b/core/migrations/025_persistent_ws.sql @@ -0,0 +1,18 @@ +-- ============================================================================= +-- 025_persistent_ws.sql +-- +-- Persistent WebSocket function settings — see plan +-- core/plans/platform/06_PERSISTENT_WS_FUNCTIONS.md +-- +-- When ws_persistent is true, the function is bound to a single WebSocket +-- connection for its lifetime; exports ws_open / ws_frame / ws_close instead +-- of the default _start. See pkg/serverless/persistent for runtime details. +-- +-- All defaults are zero / false → backward compatible: existing functions +-- continue to use the per-frame stateless WS model. +-- ============================================================================= + +ALTER TABLE functions ADD COLUMN ws_persistent BOOLEAN DEFAULT FALSE; +ALTER TABLE functions ADD COLUMN ws_idle_timeout_sec INTEGER DEFAULT 0; +ALTER TABLE functions ADD COLUMN ws_max_frame_bytes INTEGER DEFAULT 0; +ALTER TABLE functions ADD COLUMN ws_max_inflight_per_conn INTEGER DEFAULT 0; diff --git a/core/pkg/auth/rootwallet.go b/core/pkg/auth/rootwallet.go index a141816..78ebb9b 100644 --- a/core/pkg/auth/rootwallet.go +++ b/core/pkg/auth/rootwallet.go @@ -3,54 +3,58 @@ package auth import ( "bufio" "bytes" + "context" "encoding/json" "fmt" "io" "net/http" "os" - "os/exec" "strings" "time" + "github.com/DeBrosOfficial/network/pkg/rwagent" "github.com/DeBrosOfficial/network/pkg/tlsutil" ) -// IsRootWalletInstalled checks if the `rw` CLI is available in PATH +// IsRootWalletInstalled checks if the rootwallet agent is reachable. func IsRootWalletInstalled() bool { - _, err := exec.LookPath("rw") - return err == nil + client := rwagent.New(os.Getenv("RW_AGENT_SOCK")) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + return client.IsRunning(ctx) } -// getRootWalletAddress gets the EVM address from the RootWallet keystore +// getRootWalletAddress gets the EVM address from the rootwallet agent. func getRootWalletAddress() (string, error) { - cmd := exec.Command("rw", "address", "--chain", "evm") - cmd.Stderr = os.Stderr - out, err := cmd.Output() + client := rwagent.New(os.Getenv("RW_AGENT_SOCK")) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + data, err := client.GetAddress(ctx, "evm") if err != nil { - return "", fmt.Errorf("failed to get address from rw: %w", err) + return "", fmt.Errorf("failed to get address from rootwallet agent: %w", err) } - addr := strings.TrimSpace(string(out)) - if addr == "" { - return "", fmt.Errorf("rw returned empty address — run 'rw init' first") + if data.Address == "" { + return "", fmt.Errorf("rootwallet agent returned empty address") } - return addr, nil + return data.Address, nil } -// signWithRootWallet signs a message using RootWallet's EVM key. -// Stdin is passed through so the user can enter their password if the session is expired. +// signWithRootWallet signs a message using the rootwallet agent's EVM key. +// The desktop app may prompt the user for approval. func signWithRootWallet(message string) (string, error) { - cmd := exec.Command("rw", "sign", message, "--chain", "evm") - cmd.Stdin = os.Stdin - cmd.Stderr = os.Stderr - out, err := cmd.Output() + client := rwagent.New(os.Getenv("RW_AGENT_SOCK")) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + data, err := client.Sign(ctx, message, "evm") if err != nil { - return "", fmt.Errorf("failed to sign with rw: %w", err) + return "", fmt.Errorf("failed to sign with rootwallet agent: %w", err) } - sig := strings.TrimSpace(string(out)) - if sig == "" { - return "", fmt.Errorf("rw returned empty signature") + if data.Signature == "" { + return "", fmt.Errorf("rootwallet agent returned empty signature") } - return sig, nil + return data.Signature, nil } // PerformRootWalletAuthentication performs a challenge-response authentication flow diff --git a/core/pkg/cli/build/builder.go b/core/pkg/cli/build/builder.go index 2c306d4..51a32e1 100644 --- a/core/pkg/cli/build/builder.go +++ b/core/pkg/cli/build/builder.go @@ -157,6 +157,7 @@ func (b *Builder) buildOramaBinaries() error { {Name: "identity", Package: "./cmd/identity/"}, {Name: "sfu", Package: "./cmd/sfu/"}, {Name: "turn", Package: "./cmd/turn/"}, + {Name: "orama-sni-router", Package: "./cmd/sni-router/"}, } for _, bin := range binaries { @@ -197,8 +198,8 @@ func (b *Builder) buildVaultGuardian() error { return fmt.Errorf("zig not found in PATH — install from https://ziglang.org/download/") } - // Vault source is sibling to orama project - vaultDir := filepath.Join(b.projectDir, "..", "orama-vault") + // Vault source is sibling to core/ within the orama monorepo + vaultDir := filepath.Join(b.projectDir, "..", "vault") if _, err := os.Stat(filepath.Join(vaultDir, "build.zig")); err != nil { return fmt.Errorf("vault source not found at %s — expected orama-vault as sibling directory: %w", vaultDir, err) } diff --git a/core/pkg/cli/cmd/node/migrate_conf.go b/core/pkg/cli/cmd/node/migrate_conf.go new file mode 100644 index 0000000..1b9e2af --- /dev/null +++ b/core/pkg/cli/cmd/node/migrate_conf.go @@ -0,0 +1,116 @@ +package node + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/DeBrosOfficial/network/pkg/auth" + "github.com/DeBrosOfficial/network/pkg/cli" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" + "github.com/spf13/cobra" +) + +var migrateConfEnv string + +var migrateConfCmd = &cobra.Command{ + Use: "migrate-conf", + Short: "Register nodes.conf nodes with your wallet", + Long: `One-time migration: reads nodes from nodes.conf for an environment +and registers each with your wallet via the gateway API. After migration, +these nodes will appear in 'orama nodes' output. + +Requires: orama auth login (for API authentication)`, + RunE: func(cmd *cobra.Command, args []string) error { + env := migrateConfEnv + if env == "" { + active, err := cli.GetActiveEnvironment() + if err != nil { + return fmt.Errorf("failed to get active environment: %w", err) + } + env = active.Name + } + + // Load nodes from nodes.conf + nodes, err := remotessh.LoadEnvNodes(env) + if err != nil { + return fmt.Errorf("failed to load nodes.conf: %w", err) + } + + // Get gateway URL + envConfig, err := cli.GetEnvironmentByName(env) + if err != nil { + return fmt.Errorf("environment %q not configured: %w", env, err) + } + + // Load stored credentials + store, err := auth.LoadEnhancedCredentials() + if err != nil { + return fmt.Errorf("failed to load credentials: %w", err) + } + creds := store.GetDefaultCredential(envConfig.GatewayURL) + if creds == nil || creds.APIKey == "" { + return fmt.Errorf("no credentials for %s — run 'orama auth login' first", envConfig.GatewayURL) + } + + if len(nodes) == 0 { + fmt.Printf("No nodes found for environment %q in nodes.conf\n", env) + return nil + } + + fmt.Printf("Migrating %d node(s) from nodes.conf to %s...\n\n", len(nodes), env) + + httpClient := &http.Client{Timeout: 10 * time.Second} + registered := 0 + + for _, n := range nodes { + body := map[string]string{ + "ip_address": n.Host, + "environment": env, + "role": n.Role, + "ssh_user": n.User, + } + payload, _ := json.Marshal(body) + + req, err := http.NewRequest(http.MethodPost, + envConfig.GatewayURL+"/v1/operator/node/register", + bytes.NewReader(payload)) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), " %s: failed to create request: %v\n", n.Host, err) + continue + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", creds.APIKey) + + resp, err := httpClient.Do(req) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), " %s: request failed: %v\n", n.Host, err) + continue + } + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + fmt.Printf(" %s (%s): registered\n", n.Host, n.Role) + registered++ + } else if resp.StatusCode == http.StatusNotFound { + fmt.Printf(" %s: not found in cluster (node may not have joined yet)\n", n.Host) + } else { + fmt.Fprintf(cmd.ErrOrStderr(), " %s: HTTP %d: %s\n", n.Host, resp.StatusCode, string(respBody)) + } + } + + fmt.Printf("\n%d/%d nodes registered with your wallet\n", registered, len(nodes)) + if registered < len(nodes) { + fmt.Println("Nodes not found may need to join the cluster first, then re-run this command.") + } + return nil + }, +} + +func init() { + migrateConfCmd.Flags().StringVar(&migrateConfEnv, "env", "", "Environment to migrate (default: active)") +} diff --git a/core/pkg/cli/cmd/node/node.go b/core/pkg/cli/cmd/node/node.go index 74f9744..19ff3a1 100644 --- a/core/pkg/cli/cmd/node/node.go +++ b/core/pkg/cli/cmd/node/node.go @@ -32,4 +32,6 @@ func init() { Cmd.AddCommand(recoverRaftCmd) Cmd.AddCommand(enrollCmd) Cmd.AddCommand(unlockCmd) + Cmd.AddCommand(migrateConfCmd) + Cmd.AddCommand(setupCmd) } diff --git a/core/pkg/cli/cmd/node/setup.go b/core/pkg/cli/cmd/node/setup.go new file mode 100644 index 0000000..899924b --- /dev/null +++ b/core/pkg/cli/cmd/node/setup.go @@ -0,0 +1,47 @@ +package node + +import ( + "github.com/DeBrosOfficial/network/pkg/cli/production/setup" + "github.com/spf13/cobra" +) + +var setupOpts setup.Options + +var setupCmd = &cobra.Command{ + Use: "setup", + Short: "Set up a fresh VPS as an Orama node", + Long: `Bootstrap a fresh VPS into a running Orama node in one command. + +Creates an SSH key in rootwallet, installs it on the VPS, uploads the binary +archive, and runs the node install. For the first node, use --genesis to +create a new cluster. + +Examples: + # Genesis node (first node, creates new cluster) + orama node setup --ip 1.2.3.4 --password 'vps-pass' --env devnet \ + --base-domain orama-devnet.network --role nameserver --genesis + + # Join existing cluster + orama node setup --ip 5.6.7.8 --password 'vps-pass' --env devnet \ + --base-domain orama-devnet.network + + # Join as nameserver + orama node setup --ip 9.10.11.12 --password 'vps-pass' --env devnet \ + --base-domain orama-devnet.network --role nameserver`, + RunE: func(cmd *cobra.Command, args []string) error { + return setup.Run(setupOpts) + }, +} + +func init() { + setupCmd.Flags().StringVar(&setupOpts.IP, "ip", "", "Public IP address of the VPS (required)") + setupCmd.Flags().StringVar(&setupOpts.Env, "env", "", "Target environment (default: active)") + setupCmd.Flags().StringVar(&setupOpts.Role, "role", "node", "Node role: node or nameserver") + setupCmd.Flags().StringVar(&setupOpts.User, "user", "root", "SSH user on the VPS") + setupCmd.Flags().StringVar(&setupOpts.Password, "password", "", "One-time password for initial SSH access") + setupCmd.Flags().StringVar(&setupOpts.BaseDomain, "base-domain", "", "Base domain for the network") + setupCmd.Flags().StringVar(&setupOpts.Gateway, "gateway", "", "Gateway URL for invite tokens (e.g., http://1.2.3.4)") + setupCmd.Flags().BoolVar(&setupOpts.Genesis, "genesis", false, "Create a new cluster (first node)") + setupCmd.Flags().BoolVar(&setupOpts.AnyoneRelay, "anyone-relay", false, "Run as Anyone relay operator") + setupCmd.MarkFlagRequired("ip") +} diff --git a/core/pkg/cli/cmd/nodescmd/nodes.go b/core/pkg/cli/cmd/nodescmd/nodes.go new file mode 100644 index 0000000..3811768 --- /dev/null +++ b/core/pkg/cli/cmd/nodescmd/nodes.go @@ -0,0 +1,58 @@ +package nodescmd + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/DeBrosOfficial/network/pkg/cli" + "github.com/DeBrosOfficial/network/pkg/cli/noderesolver" + "github.com/spf13/cobra" +) + +var envFlag string + +// Cmd is the top-level "nodes" command — lists operator's nodes. +var Cmd = &cobra.Command{ + Use: "nodes", + Short: "List your nodes across environments", + Long: `List all nodes owned by your wallet. Queries the network API +with your stored credentials, falling back to nodes.conf. + +Requires: orama auth login (for API-based resolution)`, + RunE: func(cmd *cobra.Command, args []string) error { + env := envFlag + if env == "" { + active, err := cli.GetActiveEnvironment() + if err != nil { + return fmt.Errorf("failed to get active environment: %w", err) + } + env = active.Name + } + + nodes, err := noderesolver.ResolveNodes(env) + if err != nil { + return fmt.Errorf("failed to resolve nodes: %w", err) + } + + if len(nodes) == 0 { + fmt.Printf("No nodes found for environment %q\n", env) + fmt.Println("Register nodes with: orama node setup --env", env) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintf(w, "IP\tROLE\tUSER\tENVIRONMENT\n") + for _, n := range nodes { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", n.Host, n.Role, n.User, n.Environment) + } + w.Flush() + + fmt.Printf("\n%d node(s) in %s\n", len(nodes), env) + return nil + }, +} + +func init() { + Cmd.Flags().StringVar(&envFlag, "env", "", "Filter by environment (default: active environment)") +} diff --git a/core/pkg/cli/cmd/pushcmd/push.go b/core/pkg/cli/cmd/pushcmd/push.go new file mode 100644 index 0000000..f2c8a19 --- /dev/null +++ b/core/pkg/cli/cmd/pushcmd/push.go @@ -0,0 +1,225 @@ +package pushcmd + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/DeBrosOfficial/network/pkg/cli" + "github.com/DeBrosOfficial/network/pkg/cli/noderesolver" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" + "github.com/DeBrosOfficial/network/pkg/inspector" + "github.com/spf13/cobra" +) + +var ( + envFlag string + ipFlag string + userFlag string + fanoutFlag bool +) + +// Cmd is the top-level "push" command — upload binary archive to nodes. +var Cmd = &cobra.Command{ + Use: "push", + Short: "Push binary archive to your nodes", + Long: `Upload the pre-built binary archive to nodes and extract it. + +By default, uploads from your machine to each node sequentially. +Use --fanout to upload to one node, then fan out server-to-server (faster). + +Examples: + orama push --ip 1.2.3.4 # Push to one node + orama push --env devnet # Sequential push to all devnet nodes + orama push --env devnet --fanout # Fan out server-to-server (faster)`, + RunE: func(cmd *cobra.Command, args []string) error { + archivePath := findNewestArchive() + if archivePath == "" { + return fmt.Errorf("no binary archive found in /tmp/ (run `orama build` first)") + } + info, err := os.Stat(archivePath) + if err != nil { + return fmt.Errorf("stat archive: %w", err) + } + fmt.Printf("Archive: %s (%s)\n", filepath.Base(archivePath), formatBytes(info.Size())) + + var nodes []inspector.Node + + if ipFlag != "" { + user := userFlag + if user == "" { + user = "root" + } + vaultTarget := fmt.Sprintf("%s/%s", ipFlag, user) + env := envFlag + if env == "" { + active, _ := cli.GetActiveEnvironment() + if active != nil { + env = active.Name + } + } + if env == "sandbox" { + vaultTarget = "sandbox/root" + } + nodes = []inspector.Node{{ + Host: ipFlag, User: user, VaultTarget: vaultTarget, Environment: env, + }} + } else { + env := envFlag + if env == "" { + active, err := cli.GetActiveEnvironment() + if err != nil { + return fmt.Errorf("no --ip or --env specified and no active environment") + } + env = active.Name + } + resolved, err := noderesolver.ResolveNodes(env) + if err != nil { + return fmt.Errorf("failed to resolve nodes: %w", err) + } + if len(resolved) == 0 { + return fmt.Errorf("no nodes found for environment %q", env) + } + nodes = resolved + } + + // Prepare SSH keys + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return fmt.Errorf("failed to prepare SSH keys: %w", err) + } + defer cleanup() + + // Single node or default: upload sequentially + if len(nodes) == 1 || !fanoutFlag { + return pushDirect(nodes, archivePath) + } + + // Multi-node with --fanout: use agent forwarding + return pushFanout(nodes, archivePath) + }, +} + +func init() { + Cmd.Flags().StringVar(&envFlag, "env", "", "Target environment (default: active)") + Cmd.Flags().StringVar(&ipFlag, "ip", "", "Push to a single node by IP") + Cmd.Flags().StringVar(&userFlag, "user", "", "SSH user (default: root)") + Cmd.Flags().BoolVar(&fanoutFlag, "fanout", false, "Upload to first node, then fan out server-to-server (faster)") +} + +// pushDirect uploads the archive from local machine to each node sequentially. +func pushDirect(nodes []inspector.Node, archivePath string) error { + fmt.Printf("Pushing to %d node(s) (direct)...\n\n", len(nodes)) + + remotePath := "/tmp/" + filepath.Base(archivePath) + extractCmd := fmt.Sprintf("sudo bash -c 'mkdir -p /opt/orama && tar xzf %s -C /opt/orama && rm -f %s && /opt/orama/bin/orama version'", remotePath, remotePath) + + for _, n := range nodes { + fmt.Printf(" %s: uploading...", n.Host) + if err := remotessh.UploadFile(n, archivePath, remotePath); err != nil { + fmt.Printf(" FAILED (%v)\n", err) + continue + } + fmt.Printf(" extracting...") + if err := remotessh.RunSSHStreaming(n, extractCmd); err != nil { + fmt.Printf(" FAILED (%v)\n", err) + continue + } + fmt.Println(" OK") + } + + fmt.Println("\nPush complete") + return nil +} + +// pushFanout uploads the archive to the first node, then fans out server-to-server +// using SSH agent forwarding. +func pushFanout(nodes []inspector.Node, archivePath string) error { + fmt.Printf("Pushing to %d node(s) (fanout)...\n\n", len(nodes)) + + hub := nodes[0] + targets := nodes[1:] + remotePath := "/tmp/" + filepath.Base(archivePath) + // Hub extraction keeps the archive so it can be fanned out to targets. + // The cleanup at the end removes it. + hubExtractCmd := fmt.Sprintf("mkdir -p /opt/orama && tar xzf %s -C /opt/orama", remotePath) + // Target extraction deletes the archive after extracting. + targetExtractCmd := fmt.Sprintf("mkdir -p /opt/orama && tar xzf %s -C /opt/orama && rm -f %s", remotePath, remotePath) + + // Load SSH keys into the system ssh-agent for agent forwarding + fmt.Println(" Loading SSH keys into agent...") + if err := remotessh.LoadAgentKeys(nodes); err != nil { + fmt.Printf(" Warning: failed to load agent keys: %v\n", err) + fmt.Println(" Falling back to direct push...") + return pushDirect(nodes, archivePath) + } + + // Upload archive to hub + fmt.Printf(" %s (hub): uploading...", hub.Host) + if err := remotessh.UploadFile(hub, archivePath, remotePath); err != nil { + return fmt.Errorf("failed to upload to hub %s: %w", hub.Host, err) + } + fmt.Printf(" extracting...") + if err := remotessh.RunSSHStreaming(hub, "sudo bash -c '"+hubExtractCmd+"'"); err != nil { + return fmt.Errorf("failed to extract on hub %s: %w", hub.Host, err) + } + fmt.Println(" OK") + + // Build the fanout command — hub SCPs to all targets in parallel + var fanoutParts []string + for _, t := range targets { + scpCmd := fmt.Sprintf( + "scp -o StrictHostKeyChecking=accept-new -o IdentitiesOnly=no %s %s@%s:%s && ssh -o StrictHostKeyChecking=accept-new %s@%s 'sudo bash -c \"%s\"' && echo '%s: done'", + remotePath, t.User, t.Host, remotePath, + t.User, t.Host, targetExtractCmd, + t.Host, + ) + fanoutParts = append(fanoutParts, "("+scpCmd+") &") + } + fanoutParts = append(fanoutParts, "wait", "echo 'Fanout complete'") + fanoutScript := strings.Join(fanoutParts, "\n") + + // Base64-encode the script to avoid shell quoting conflicts — the script + // contains single quotes (ssh '...') that would break a bash -c '...' wrapper. + encoded := base64.StdEncoding.EncodeToString([]byte(fanoutScript)) + runCmd := fmt.Sprintf("echo %s | base64 -d | bash", encoded) + + fmt.Printf(" Fanning out to %d nodes from %s...\n", len(targets), hub.Host) + if err := remotessh.RunSSHStreaming(hub, runCmd, remotessh.WithAgentForward()); err != nil { + fmt.Printf(" Fanout failed: %v\n", err) + fmt.Println(" Some nodes may not have been updated") + } + + // Clean up archive on hub + remotessh.RunSSHStreaming(hub, "rm -f "+remotePath) + + fmt.Println("\nPush complete") + return nil +} + +func findNewestArchive() string { + matches, _ := filepath.Glob("/tmp/orama-*-linux-*.tar.gz") + if len(matches) == 0 { + return "" + } + sort.Slice(matches, func(i, j int) bool { + fi, _ := os.Stat(matches[i]) + fj, _ := os.Stat(matches[j]) + if fi == nil || fj == nil { + return false + } + return fi.ModTime().After(fj.ModTime()) + }) + return matches[0] +} + +func formatBytes(b int64) string { + const mb = 1024 * 1024 + if b >= mb { + return fmt.Sprintf("%.1f MB", float64(b)/float64(mb)) + } + return fmt.Sprintf("%d KB", b/1024) +} diff --git a/core/pkg/cli/cmd/rolloutcmd/rollout.go b/core/pkg/cli/cmd/rolloutcmd/rollout.go new file mode 100644 index 0000000..40f9717 --- /dev/null +++ b/core/pkg/cli/cmd/rolloutcmd/rollout.go @@ -0,0 +1,234 @@ +package rolloutcmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/DeBrosOfficial/network/pkg/cli" + "github.com/DeBrosOfficial/network/pkg/cli/noderesolver" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" + "github.com/DeBrosOfficial/network/pkg/inspector" + "github.com/spf13/cobra" +) + +var ( + envFlag string + delaySec int +) + +// Cmd is the top-level "rollout" command — build + push + rolling upgrade. +var Cmd = &cobra.Command{ + Use: "rollout", + Short: "Rolling upgrade of your nodes", + Long: `Build, push, and perform a rolling upgrade on all your nodes in an environment. +Upgrades followers first, leader last, with health checks between each node.`, + RunE: func(cmd *cobra.Command, args []string) error { + env := envFlag + if env == "" { + active, err := cli.GetActiveEnvironment() + if err != nil { + return fmt.Errorf("failed to get active environment: %w", err) + } + env = active.Name + } + + nodes, err := noderesolver.ResolveNodes(env) + if err != nil { + return fmt.Errorf("failed to resolve nodes: %w", err) + } + if len(nodes) == 0 { + return fmt.Errorf("no nodes found for environment %q", env) + } + + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return fmt.Errorf("failed to prepare SSH keys: %w", err) + } + defer cleanup() + + fmt.Printf("Rolling out to %d node(s) in %s\n\n", len(nodes), env) + + // Step 1: Find archive + archivePath := findNewestArchive() + if archivePath == "" { + return fmt.Errorf("no binary archive found in /tmp/ (run `orama build` first)") + } + info, err := os.Stat(archivePath) + if err != nil { + return fmt.Errorf("stat archive %s: %w", archivePath, err) + } + fmt.Printf("Archive: %s (%s)\n\n", filepath.Base(archivePath), formatBytes(info.Size())) + + // Step 2: Push archive to all nodes + fmt.Println("Pushing archive to all nodes...") + if err := pushArchive(nodes, archivePath); err != nil { + return err + } + + // Step 3: Rolling upgrade — followers first, leader last + fmt.Println("\nRolling upgrade (followers first, leader last)...") + + leaderIdx := findLeaderIndex(nodes) + if leaderIdx < 0 { + fmt.Fprintf(os.Stderr, " Warning: could not detect RQLite leader, upgrading in order\n") + } + + // Determine SSH options based on environment + var sshOpts []remotessh.SSHOption + if env == "sandbox" { + sshOpts = append(sshOpts, remotessh.WithNoHostKeyCheck()) + } + + delay := time.Duration(delaySec) * time.Second + + // Upgrade non-leaders first + count := 0 + for i := range nodes { + if i == leaderIdx { + continue + } + count++ + if err := upgradeNode(nodes[i], count, len(nodes), sshOpts); err != nil { + return err + } + if count < len(nodes) { + fmt.Printf(" Waiting %s before next node...\n", delay) + time.Sleep(delay) + } + } + + // Upgrade leader last + if leaderIdx >= 0 { + count++ + if err := upgradeNode(nodes[leaderIdx], count, len(nodes), sshOpts); err != nil { + return err + } + } + + fmt.Printf("\nRollout complete for %s (%d nodes)\n", env, len(nodes)) + return nil + }, +} + +func init() { + Cmd.Flags().StringVar(&envFlag, "env", "", "Environment (default: active)") + Cmd.Flags().IntVar(&delaySec, "delay", 30, "Seconds to wait between node upgrades") +} + +// findLeaderIndex returns the index of the RQLite leader, or -1 if unknown. +func findLeaderIndex(nodes []inspector.Node) int { + for i, n := range nodes { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + result := inspector.RunSSH(ctx, n, "curl -sf http://localhost:5001/status 2>/dev/null | grep -o '\"state\":\"[^\"]*\"'") + cancel() + if result.OK() && strings.Contains(result.Stdout, "Leader") { + return i + } + } + return -1 +} + +// upgradeNode performs orama node upgrade --restart on a single node. +func upgradeNode(node inspector.Node, current, total int, sshOpts []remotessh.SSHOption) error { + fmt.Printf(" [%d/%d] Upgrading %s...\n", current, total, node.Host) + + // Pre-replace orama CLI binary to avoid ETXTBSY + preReplace := "rm -f /usr/local/bin/orama && cp /opt/orama/bin/orama /usr/local/bin/orama" + if err := remotessh.RunSSHStreaming(node, preReplace, sshOpts...); err != nil { + return fmt.Errorf("pre-replace orama binary on %s: %w", node.Host, err) + } + + if err := remotessh.RunSSHStreaming(node, "orama node upgrade --restart", sshOpts...); err != nil { + return fmt.Errorf("upgrade %s: %w", node.Host, err) + } + + // Wait for health + fmt.Printf(" Checking health...") + if err := waitForHealth(node, 2*time.Minute); err != nil { + fmt.Printf(" WARN: %v\n", err) + } else { + fmt.Println(" OK") + } + + return nil +} + +// pushArchive uploads the archive to the first node, then fans out server-to-server. +func pushArchive(nodes []inspector.Node, archivePath string) error { + if len(nodes) == 0 { + return nil + } + + remotePath := "/tmp/" + filepath.Base(archivePath) + + // Upload to first node + hub := nodes[0] + fmt.Printf(" Uploading to %s...\n", hub.Host) + if err := remotessh.UploadFile(hub, archivePath, remotePath); err != nil { + return fmt.Errorf("upload to %s: %w", hub.Host, err) + } + + // Extract on hub + extractCmd := fmt.Sprintf("mkdir -p /opt/orama && tar xzf %s -C /opt/orama && rm -f %s", remotePath, remotePath) + if err := remotessh.RunSSHStreaming(hub, extractCmd); err != nil { + return fmt.Errorf("extract on %s: %w", hub.Host, err) + } + + // For remaining nodes, upload directly and extract + for _, n := range nodes[1:] { + fmt.Printf(" Uploading to %s...\n", n.Host) + if err := remotessh.UploadFile(n, archivePath, remotePath); err != nil { + return fmt.Errorf("upload to %s: %w", n.Host, err) + } + if err := remotessh.RunSSHStreaming(n, extractCmd); err != nil { + return fmt.Errorf("extract on %s: %w", n.Host, err) + } + } + + return nil +} + +// waitForHealth polls RQLite health on a node until it reaches Leader or Follower state. +func waitForHealth(node inspector.Node, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + result := inspector.RunSSH(ctx, node, "curl -sf http://localhost:5001/status 2>/dev/null | grep -o '\"state\":\"[^\"]*\"'") + cancel() + if result.OK() && (strings.Contains(result.Stdout, "Leader") || strings.Contains(result.Stdout, "Follower")) { + return nil + } + time.Sleep(3 * time.Second) + } + return fmt.Errorf("timed out waiting for healthy state on %s", node.Host) +} + +// findNewestArchive finds the newest orama binary archive in /tmp/. +func findNewestArchive() string { + matches, err := filepath.Glob("/tmp/orama-*-linux-*.tar.gz") + if err != nil || len(matches) == 0 { + return "" + } + sort.Slice(matches, func(i, j int) bool { + fi, _ := os.Stat(matches[i]) + fj, _ := os.Stat(matches[j]) + if fi == nil || fj == nil { + return false + } + return fi.ModTime().After(fj.ModTime()) + }) + return matches[0] +} + +func formatBytes(b int64) string { + const mb = 1024 * 1024 + if b >= mb { + return fmt.Sprintf("%.1f MB", float64(b)/float64(mb)) + } + return fmt.Sprintf("%d KB", b/1024) +} diff --git a/core/pkg/cli/cmd/sshcmd/ssh.go b/core/pkg/cli/cmd/sshcmd/ssh.go new file mode 100644 index 0000000..75796b9 --- /dev/null +++ b/core/pkg/cli/cmd/sshcmd/ssh.go @@ -0,0 +1,101 @@ +package sshcmd + +import ( + "fmt" + "os" + "os/exec" + + "github.com/DeBrosOfficial/network/pkg/cli" + "github.com/DeBrosOfficial/network/pkg/cli/noderesolver" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" + "github.com/DeBrosOfficial/network/pkg/inspector" + "github.com/spf13/cobra" +) + +var envFlag string + +// Cmd is the top-level "ssh" command — SSH into any node by IP or hostname. +var Cmd = &cobra.Command{ + Use: "ssh [-- command]", + Short: "SSH into a node", + Long: `SSH into a node by IP address or hostname. +Resolves the SSH key from rootwallet automatically. + +Pass a command after the IP to run it non-interactively: + orama ssh 1.2.3.4 'sudo systemctl status orama-node'`, + Args: cobra.MinimumNArgs(1), + DisableFlagParsing: false, + RunE: func(cmd *cobra.Command, args []string) error { + target := args[0] + remoteCmd := "" + if len(args) > 1 { + remoteCmd = args[1] + } + + env := envFlag + if env == "" { + active, err := cli.GetActiveEnvironment() + if err != nil { + return fmt.Errorf("failed to get active environment: %w", err) + } + env = active.Name + } + + // Resolve nodes to find the target + nodes, err := noderesolver.ResolveNodes(env) + if err != nil { + return fmt.Errorf("failed to resolve nodes: %w", err) + } + + // Match by IP + for _, n := range nodes { + if n.Host == target { + return sshInto(n, remoteCmd) + } + } + + // Not found — try direct SSH with default vault target + fmt.Printf("Node %q not found in %s nodes, attempting direct SSH...\n", target, env) + return sshInto(inspector.Node{ + Host: target, + User: "root", + VaultTarget: target + "/root", + }, remoteCmd) + }, +} + +func init() { + Cmd.Flags().StringVar(&envFlag, "env", "", "Environment to search (default: active)") +} + +func sshInto(node inspector.Node, remoteCmd string) error { + nodes := []inspector.Node{node} + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return fmt.Errorf("failed to resolve SSH key: %w", err) + } + defer cleanup() + + keyPath := nodes[0].SSHKey + + sshBin, err := exec.LookPath("ssh") + if err != nil { + return fmt.Errorf("ssh not found in PATH: %w", err) + } + + sshArgs := []string{ + "-i", keyPath, + "-o", "StrictHostKeyChecking=accept-new", + "-o", "IdentitiesOnly=yes", + fmt.Sprintf("%s@%s", node.User, node.Host), + } + if remoteCmd != "" { + sshArgs = append(sshArgs, remoteCmd) + } + + sshCmd := exec.Command(sshBin, sshArgs...) + sshCmd.Stdin = os.Stdin + sshCmd.Stdout = os.Stdout + sshCmd.Stderr = os.Stderr + return sshCmd.Run() +} diff --git a/core/pkg/cli/cmd/statuscmd/status.go b/core/pkg/cli/cmd/statuscmd/status.go new file mode 100644 index 0000000..0d9547d --- /dev/null +++ b/core/pkg/cli/cmd/statuscmd/status.go @@ -0,0 +1,143 @@ +package statuscmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sync" + "text/tabwriter" + "time" + + "github.com/DeBrosOfficial/network/pkg/cli" + "github.com/DeBrosOfficial/network/pkg/cli/noderesolver" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" + "github.com/DeBrosOfficial/network/pkg/inspector" + "github.com/spf13/cobra" +) + +var ( + envFlag string + jsonFlag bool +) + +// Cmd is the top-level "status" command — health check for operator's nodes. +var Cmd = &cobra.Command{ + Use: "status", + Short: "Show health status of your nodes", + Long: `Check the health of all your nodes in an environment. +SSHes into each node and runs orama node report to collect health data.`, + RunE: func(cmd *cobra.Command, args []string) error { + env := envFlag + if env == "" { + active, err := cli.GetActiveEnvironment() + if err != nil { + return fmt.Errorf("failed to get active environment: %w", err) + } + env = active.Name + } + + nodes, err := noderesolver.ResolveNodes(env) + if err != nil { + return fmt.Errorf("failed to resolve nodes: %w", err) + } + + if len(nodes) == 0 { + fmt.Printf("No nodes found for environment %q\n", env) + return nil + } + + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return fmt.Errorf("failed to prepare SSH keys: %w", err) + } + defer cleanup() + + fmt.Printf("Checking %d node(s) in %s...\n\n", len(nodes), env) + + type nodeResult struct { + Host string `json:"host"` + Role string `json:"role"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + } + + results := make([]nodeResult, len(nodes)) + var wg sync.WaitGroup + + for i, n := range nodes { + wg.Add(1) + go func(idx int, node inspector.Node) { + defer wg.Done() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := inspector.RunSSH(ctx, node, "sudo orama node report --json") + nr := nodeResult{Host: node.Host, Role: node.Role} + + if !result.OK() { + nr.Status = "unreachable" + nr.Error = fmt.Sprintf("SSH failed (exit %d)", result.ExitCode) + if result.Stderr != "" { + nr.Error = result.Stderr + if len(nr.Error) > 100 { + nr.Error = nr.Error[:100] + "..." + } + } + results[idx] = nr + return + } + + var report struct { + Gateway struct { + Responsive bool `json:"responsive"` + } `json:"gateway"` + RQLite struct { + RaftState string `json:"raft_state"` + } `json:"rqlite"` + } + if err := json.Unmarshal([]byte(result.Stdout), &report); err != nil { + nr.Status = "unknown" + nr.Error = "failed to parse report" + results[idx] = nr + return + } + + if report.Gateway.Responsive && (report.RQLite.RaftState == "Leader" || report.RQLite.RaftState == "Follower") { + nr.Status = "healthy" + } else { + nr.Status = "degraded" + } + results[idx] = nr + }(i, n) + } + wg.Wait() + + if jsonFlag { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(results) + } + + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintf(w, "IP\tROLE\tSTATUS\tDETAILS\n") + healthy := 0 + for _, r := range results { + details := r.Error + if r.Status == "healthy" { + healthy++ + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Host, r.Role, r.Status, details) + } + w.Flush() + + fmt.Printf("\n%d/%d nodes healthy\n", healthy, len(results)) + return nil + }, +} + +func init() { + Cmd.Flags().StringVar(&envFlag, "env", "", "Environment (default: active)") + Cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON") +} diff --git a/core/pkg/cli/env_commands.go b/core/pkg/cli/env_commands.go index 6c9b8cb..bd8e67d 100644 --- a/core/pkg/cli/env_commands.go +++ b/core/pkg/cli/env_commands.go @@ -164,30 +164,8 @@ func handleEnvAdd(args []string) { os.Exit(1) } - envConfig, err := LoadEnvironmentConfig() - if err != nil { - fmt.Fprintf(os.Stderr, "❌ Failed to load environment config: %v\n", err) - os.Exit(1) - } - - // Check if environment already exists - for _, env := range envConfig.Environments { - if env.Name == name { - fmt.Fprintf(os.Stderr, "❌ Environment '%s' already exists\n", name) - os.Exit(1) - } - } - - // Add new environment - envConfig.Environments = append(envConfig.Environments, Environment{ - Name: name, - GatewayURL: gatewayURL, - Description: description, - IsActive: false, - }) - - if err := SaveEnvironmentConfig(envConfig); err != nil { - fmt.Fprintf(os.Stderr, "❌ Failed to save environment config: %v\n", err) + if err := AddEnvironment(name, gatewayURL, description); err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to add environment: %v\n", err) os.Exit(1) } @@ -206,37 +184,8 @@ func handleEnvRemove(args []string) { name := args[0] - envConfig, err := LoadEnvironmentConfig() - if err != nil { - fmt.Fprintf(os.Stderr, "❌ Failed to load environment config: %v\n", err) - os.Exit(1) - } - - // Find and remove environment - found := false - newEnvs := make([]Environment, 0, len(envConfig.Environments)) - for _, env := range envConfig.Environments { - if env.Name == name { - found = true - continue - } - newEnvs = append(newEnvs, env) - } - - if !found { - fmt.Fprintf(os.Stderr, "❌ Environment '%s' not found\n", name) - os.Exit(1) - } - - envConfig.Environments = newEnvs - - // If we removed the active environment, switch to devnet - if envConfig.ActiveEnvironment == name { - envConfig.ActiveEnvironment = "devnet" - } - - if err := SaveEnvironmentConfig(envConfig); err != nil { - fmt.Fprintf(os.Stderr, "❌ Failed to save environment config: %v\n", err) + if err := RemoveEnvironment(name); err != nil { + fmt.Fprintf(os.Stderr, "❌ Failed to remove environment: %v\n", err) os.Exit(1) } diff --git a/core/pkg/cli/environment.go b/core/pkg/cli/environment.go index b92bc5f..5a61737 100644 --- a/core/pkg/cli/environment.go +++ b/core/pkg/cli/environment.go @@ -45,8 +45,11 @@ var DefaultEnvironments = []Environment{ }, } -// GetEnvironmentConfigPath returns the path to the environment config file -func GetEnvironmentConfigPath() (string, error) { +// getEnvironmentConfigPathFn is the function used to resolve the config path. +// Tests override this to point at a temp file. +var getEnvironmentConfigPathFn = getEnvironmentConfigPathDefault + +func getEnvironmentConfigPathDefault() (string, error) { configDir, err := config.ConfigDir() if err != nil { return "", fmt.Errorf("failed to get config directory: %w", err) @@ -54,6 +57,11 @@ func GetEnvironmentConfigPath() (string, error) { return filepath.Join(configDir, "environments.json"), nil } +// GetEnvironmentConfigPath returns the path to the environment config file +func GetEnvironmentConfigPath() (string, error) { + return getEnvironmentConfigPathFn() +} + // LoadEnvironmentConfig loads the environment configuration func LoadEnvironmentConfig() (*EnvironmentConfig, error) { path, err := GetEnvironmentConfigPath() @@ -170,6 +178,63 @@ func GetEnvironmentByName(name string) (*Environment, error) { return nil, fmt.Errorf("environment '%s' not found", name) } +// AddEnvironment adds a new environment or updates an existing one. +// If an environment with the same name already exists, its gateway URL and +// description are updated in place. +func AddEnvironment(name, gatewayURL, description string) error { + envConfig, err := LoadEnvironmentConfig() + if err != nil { + return err + } + + for i, env := range envConfig.Environments { + if env.Name == name { + envConfig.Environments[i].GatewayURL = gatewayURL + envConfig.Environments[i].Description = description + return SaveEnvironmentConfig(envConfig) + } + } + + envConfig.Environments = append(envConfig.Environments, Environment{ + Name: name, + GatewayURL: gatewayURL, + Description: description, + }) + + return SaveEnvironmentConfig(envConfig) +} + +// RemoveEnvironment removes an environment by name. If the removed environment +// was active, the active environment falls back to "devnet". +func RemoveEnvironment(name string) error { + envConfig, err := LoadEnvironmentConfig() + if err != nil { + return err + } + + newEnvs := make([]Environment, 0, len(envConfig.Environments)) + found := false + for _, env := range envConfig.Environments { + if env.Name == name { + found = true + continue + } + newEnvs = append(newEnvs, env) + } + + if !found { + return nil // already absent, nothing to do + } + + envConfig.Environments = newEnvs + + if envConfig.ActiveEnvironment == name { + envConfig.ActiveEnvironment = "devnet" + } + + return SaveEnvironmentConfig(envConfig) +} + // InitializeEnvironments initializes the environment config with defaults func InitializeEnvironments() error { path, err := GetEnvironmentConfigPath() diff --git a/core/pkg/cli/environment_test.go b/core/pkg/cli/environment_test.go new file mode 100644 index 0000000..ff7cc17 --- /dev/null +++ b/core/pkg/cli/environment_test.go @@ -0,0 +1,131 @@ +package cli + +import ( + "encoding/json" + "os" + "testing" +) + +// writeTestConfig writes an EnvironmentConfig to a temp file and returns +// a helper that patches GetEnvironmentConfigPath to return that path. +// The returned cleanup restores the original function. +func writeTestConfig(t *testing.T, cfg *EnvironmentConfig) func() { + t.Helper() + + f, err := os.CreateTemp(t.TempDir(), "envconfig-*.json") + if err != nil { + t.Fatalf("create temp file: %v", err) + } + data, _ := json.MarshalIndent(cfg, "", " ") + if _, err := f.Write(data); err != nil { + t.Fatalf("write temp file: %v", err) + } + f.Close() + + origFn := getEnvironmentConfigPathFn + getEnvironmentConfigPathFn = func() (string, error) { return f.Name(), nil } + return func() { getEnvironmentConfigPathFn = origFn } +} + +func defaultTestConfig() *EnvironmentConfig { + return &EnvironmentConfig{ + Environments: []Environment{ + {Name: "sandbox", GatewayURL: "https://dbrs.space", Description: "Sandbox cluster"}, + {Name: "devnet", GatewayURL: "https://orama-devnet.network", Description: "Development network"}, + {Name: "testnet", GatewayURL: "https://orama-testnet.network", Description: "Test network"}, + }, + ActiveEnvironment: "sandbox", + } +} + +func TestAddEnvironment_new(t *testing.T) { + cleanup := writeTestConfig(t, defaultTestConfig()) + defer cleanup() + + if err := AddEnvironment("staging", "https://staging.example.com", "Staging env"); err != nil { + t.Fatalf("AddEnvironment: %v", err) + } + + env, err := GetEnvironmentByName("staging") + if err != nil { + t.Fatalf("GetEnvironmentByName: %v", err) + } + if env.GatewayURL != "https://staging.example.com" { + t.Errorf("GatewayURL = %q, want %q", env.GatewayURL, "https://staging.example.com") + } + if env.Description != "Staging env" { + t.Errorf("Description = %q, want %q", env.Description, "Staging env") + } +} + +func TestAddEnvironment_update(t *testing.T) { + cleanup := writeTestConfig(t, defaultTestConfig()) + defer cleanup() + + if err := AddEnvironment("sandbox", "https://new.example.com", "Updated sandbox"); err != nil { + t.Fatalf("AddEnvironment: %v", err) + } + + env, err := GetEnvironmentByName("sandbox") + if err != nil { + t.Fatalf("GetEnvironmentByName: %v", err) + } + if env.GatewayURL != "https://new.example.com" { + t.Errorf("GatewayURL = %q, want %q", env.GatewayURL, "https://new.example.com") + } + if env.Description != "Updated sandbox" { + t.Errorf("Description = %q, want %q", env.Description, "Updated sandbox") + } + + // Verify upsert didn't create a duplicate + cfg, _ := LoadEnvironmentConfig() + count := 0 + for _, e := range cfg.Environments { + if e.Name == "sandbox" { + count++ + } + } + if count != 1 { + t.Errorf("sandbox entries = %d, want 1", count) + } +} + +func TestRemoveEnvironment_existing(t *testing.T) { + cleanup := writeTestConfig(t, defaultTestConfig()) + defer cleanup() + + if err := RemoveEnvironment("testnet"); err != nil { + t.Fatalf("RemoveEnvironment: %v", err) + } + + _, err := GetEnvironmentByName("testnet") + if err == nil { + t.Error("expected error for removed environment, got nil") + } +} + +func TestRemoveEnvironment_absent(t *testing.T) { + cleanup := writeTestConfig(t, defaultTestConfig()) + defer cleanup() + + if err := RemoveEnvironment("nonexistent"); err != nil { + t.Errorf("RemoveEnvironment(absent) = %v, want nil", err) + } +} + +func TestRemoveEnvironment_active_falls_back(t *testing.T) { + cleanup := writeTestConfig(t, defaultTestConfig()) + defer cleanup() + + if err := RemoveEnvironment("sandbox"); err != nil { + t.Fatalf("RemoveEnvironment: %v", err) + } + + cfg, err := LoadEnvironmentConfig() + if err != nil { + t.Fatalf("LoadEnvironmentConfig: %v", err) + } + if cfg.ActiveEnvironment != "devnet" { + t.Errorf("ActiveEnvironment = %q, want %q", cfg.ActiveEnvironment, "devnet") + } +} diff --git a/core/pkg/cli/functions/helpers.go b/core/pkg/cli/functions/helpers.go index f0baf84..41a2b79 100644 --- a/core/pkg/cli/functions/helpers.go +++ b/core/pkg/cli/functions/helpers.go @@ -24,6 +24,14 @@ type FunctionConfig struct { Timeout int `yaml:"timeout"` Retry RetryConfig `yaml:"retry"` Env map[string]string `yaml:"env"` + + // Persistent WebSocket settings — when WSPersistent is true, the function + // must export ws_open / ws_frame / ws_close instead of running per-frame + // stateless. See core/plans/platform/06_PERSISTENT_WS_FUNCTIONS.md. + WSPersistent bool `yaml:"ws_persistent"` + WSIdleTimeoutSec int `yaml:"ws_idle_timeout_sec"` + WSMaxFrameBytes int `yaml:"ws_max_frame_bytes"` + WSMaxInflightPerConn int `yaml:"ws_max_inflight_per_conn"` } // RetryConfig holds retry settings. @@ -198,11 +206,28 @@ func uploadWASMFunction(wasmPath string, cfg *FunctionConfig) (map[string]interf writer.WriteField("retry_count", strconv.Itoa(cfg.Retry.Count)) writer.WriteField("retry_delay_seconds", strconv.Itoa(cfg.Retry.Delay)) - // Add env vars as metadata JSON + // Build metadata JSON. The deploy handler json.Unmarshal()s this into + // FunctionDefinition first, then overlays the explicit form fields below. + // Any field that has no explicit form-field equivalent (env vars, the + // ws_* persistent settings) MUST live in this blob. + metaObj := map[string]interface{}{} if len(cfg.Env) > 0 { - metadata, _ := json.Marshal(map[string]interface{}{ - "env_vars": cfg.Env, - }) + metaObj["env_vars"] = cfg.Env + } + if cfg.WSPersistent { + metaObj["ws_persistent"] = true + } + if cfg.WSIdleTimeoutSec > 0 { + metaObj["ws_idle_timeout_sec"] = cfg.WSIdleTimeoutSec + } + if cfg.WSMaxFrameBytes > 0 { + metaObj["ws_max_frame_bytes"] = cfg.WSMaxFrameBytes + } + if cfg.WSMaxInflightPerConn > 0 { + metaObj["ws_max_inflight_per_conn"] = cfg.WSMaxInflightPerConn + } + if len(metaObj) > 0 { + metadata, _ := json.Marshal(metaObj) writer.WriteField("metadata", string(metadata)) } diff --git a/core/pkg/cli/functions/triggers.go b/core/pkg/cli/functions/triggers.go index 3b56e7f..4c4f842 100644 --- a/core/pkg/cli/functions/triggers.go +++ b/core/pkg/cli/functions/triggers.go @@ -10,30 +10,42 @@ import ( "github.com/spf13/cobra" ) -var triggerTopic string +var ( + triggerTopic string + triggerSchedule string +) // TriggersCmd is the parent command for trigger management. var TriggersCmd = &cobra.Command{ Use: "triggers", - Short: "Manage function PubSub triggers", - Long: `Add, list, and delete PubSub triggers for your serverless functions. + Short: "Manage function PubSub and cron triggers", + Long: `Add, list, and delete triggers for your serverless functions. -When a message is published to a topic, all functions with a trigger on -that topic are automatically invoked with the message as input. +PubSub: when a message is published to a topic, every function with a +matching trigger is invoked with the message as input. + +Cron: a function is invoked on a schedule (5-field crontab, or 6-field +crontab with a leading seconds column). Examples: orama function triggers add my-function --topic calls:invite + orama function triggers add my-function --schedule "0 3 * * *" + orama function triggers add my-function --schedule "*/30 * * * * *" orama function triggers list my-function orama function triggers delete my-function `, } -// TriggersAddCmd adds a PubSub trigger to a function. +// TriggersAddCmd adds a PubSub or Cron trigger to a function. var TriggersAddCmd = &cobra.Command{ Use: "add ", - Short: "Add a PubSub trigger", - Long: "Registers a PubSub trigger so the function is invoked when a message is published to the topic.", - Args: cobra.ExactArgs(1), - RunE: runTriggersAdd, + Short: "Add a PubSub or Cron trigger", + Long: `Registers a trigger that invokes the function automatically. + +Pass exactly one of --topic (PubSub) or --schedule (cron). Schedules +accept either 5-field crontab (minute hour dom month dow) or 6-field +with seconds (sec minute hour dom month dow).`, + Args: cobra.ExactArgs(1), + RunE: runTriggersAdd, } // TriggersListCmd lists triggers for a function. @@ -57,15 +69,18 @@ func init() { TriggersCmd.AddCommand(TriggersListCmd) TriggersCmd.AddCommand(TriggersDeleteCmd) - TriggersAddCmd.Flags().StringVar(&triggerTopic, "topic", "", "PubSub topic to trigger on (required)") - TriggersAddCmd.MarkFlagRequired("topic") + TriggersAddCmd.Flags().StringVar(&triggerTopic, "topic", "", "PubSub topic to trigger on") + TriggersAddCmd.Flags().StringVar(&triggerSchedule, "schedule", "", "Cron expression to trigger on (e.g. \"0 3 * * *\")") + TriggersAddCmd.MarkFlagsMutuallyExclusive("topic", "schedule") + TriggersAddCmd.MarkFlagsOneRequired("topic", "schedule") } func runTriggersAdd(cmd *cobra.Command, args []string) error { funcName := args[0] body, _ := json.Marshal(map[string]string{ - "topic": triggerTopic, + "topic": triggerTopic, + "cron_expression": triggerSchedule, }) resp, err := apiRequest("POST", "/v1/functions/"+funcName+"/triggers", bytes.NewReader(body), "application/json") @@ -88,7 +103,11 @@ func runTriggersAdd(cmd *cobra.Command, args []string) error { return fmt.Errorf("failed to parse response: %w", err) } - fmt.Printf("Trigger added: %s → %s (id: %s)\n", triggerTopic, funcName, result["trigger_id"]) + if triggerSchedule != "" { + fmt.Printf("Trigger added: cron(%s) → %s (id: %s)\n", triggerSchedule, funcName, result["trigger_id"]) + } else { + fmt.Printf("Trigger added: %s → %s (id: %s)\n", triggerTopic, funcName, result["trigger_id"]) + } return nil } diff --git a/core/pkg/cli/monitor/alerts.go b/core/pkg/cli/monitor/alerts.go index 49e1437..317b74b 100644 --- a/core/pkg/cli/monitor/alerts.go +++ b/core/pkg/cli/monitor/alerts.go @@ -124,6 +124,7 @@ func DeriveAlerts(snap *ClusterSnapshot) []Alert { alerts = append(alerts, checkNodeNetwork(r, host)...) alerts = append(alerts, checkNodeOlric(r, host)...) alerts = append(alerts, checkNodeIPFS(r, host)...) + alerts = append(alerts, checkNodeVault(r, host)...) alerts = append(alerts, checkNodeGateway(r, host)...) } @@ -866,6 +867,41 @@ func checkNodeIPFS(r *report.NodeReport, host string) []Alert { return alerts } +func checkNodeVault(r *report.NodeReport, host string) []Alert { + if r.Vault == nil { + return nil + } + var alerts []Alert + + if !r.Vault.ServiceActive { + alerts = append(alerts, Alert{AlertCritical, "vault", host, "Vault service not running"}) + return alerts + } + + if !r.Vault.Responsive { + alerts = append(alerts, Alert{AlertWarning, "vault", host, "Vault not responding to health queries"}) + return alerts + } + + switch r.Vault.Status { + case "unavailable": + alerts = append(alerts, Alert{AlertCritical, "vault", host, + fmt.Sprintf("Vault unavailable: %d/%d guardians healthy (need %d for reads)", + r.Vault.Healthy, r.Vault.Guardians, r.Vault.Threshold)}) + case "degraded": + alerts = append(alerts, Alert{AlertWarning, "vault", host, + fmt.Sprintf("Vault degraded: %d/%d guardians healthy (need %d for writes)", + r.Vault.Healthy, r.Vault.Guardians, r.Vault.WriteQuorum)}) + } + + if r.Vault.RestartCount > 3 { + alerts = append(alerts, Alert{AlertWarning, "vault", host, + fmt.Sprintf("Vault restarted %d times", r.Vault.RestartCount)}) + } + + return alerts +} + func checkNodeGateway(r *report.NodeReport, host string) []Alert { if r.Gateway == nil { return nil diff --git a/core/pkg/cli/monitor/alerts_vault_test.go b/core/pkg/cli/monitor/alerts_vault_test.go new file mode 100644 index 0000000..2ea302d --- /dev/null +++ b/core/pkg/cli/monitor/alerts_vault_test.go @@ -0,0 +1,120 @@ +package monitor + +import ( + "testing" + + "github.com/DeBrosOfficial/network/pkg/cli/production/report" +) + +func TestCheckNodeVault_nil(t *testing.T) { + r := &report.NodeReport{} + alerts := checkNodeVault(r, "10.0.0.1") + if len(alerts) != 0 { + t.Errorf("expected 0 alerts for nil vault, got %d", len(alerts)) + } +} + +func TestCheckNodeVault_serviceInactive(t *testing.T) { + r := &report.NodeReport{ + Vault: &report.VaultReport{ServiceActive: false}, + } + alerts := checkNodeVault(r, "10.0.0.1") + if len(alerts) != 1 { + t.Fatalf("expected 1 alert, got %d", len(alerts)) + } + if alerts[0].Severity != AlertCritical { + t.Errorf("expected critical, got %s", alerts[0].Severity) + } +} + +func TestCheckNodeVault_unresponsive(t *testing.T) { + r := &report.NodeReport{ + Vault: &report.VaultReport{ServiceActive: true, Responsive: false}, + } + alerts := checkNodeVault(r, "10.0.0.1") + if len(alerts) != 1 { + t.Fatalf("expected 1 alert, got %d", len(alerts)) + } + if alerts[0].Severity != AlertWarning { + t.Errorf("expected warning, got %s", alerts[0].Severity) + } +} + +func TestCheckNodeVault_unavailable(t *testing.T) { + r := &report.NodeReport{ + Vault: &report.VaultReport{ + ServiceActive: true, + Responsive: true, + Status: "unavailable", + Guardians: 5, + Healthy: 1, + Threshold: 3, + WriteQuorum: 4, + }, + } + alerts := checkNodeVault(r, "10.0.0.1") + if len(alerts) != 1 { + t.Fatalf("expected 1 alert, got %d", len(alerts)) + } + if alerts[0].Severity != AlertCritical { + t.Errorf("expected critical, got %s", alerts[0].Severity) + } +} + +func TestCheckNodeVault_degraded(t *testing.T) { + r := &report.NodeReport{ + Vault: &report.VaultReport{ + ServiceActive: true, + Responsive: true, + Status: "degraded", + Guardians: 5, + Healthy: 3, + Threshold: 3, + WriteQuorum: 4, + }, + } + alerts := checkNodeVault(r, "10.0.0.1") + if len(alerts) != 1 { + t.Fatalf("expected 1 alert, got %d", len(alerts)) + } + if alerts[0].Severity != AlertWarning { + t.Errorf("expected warning, got %s", alerts[0].Severity) + } +} + +func TestCheckNodeVault_excessiveRestarts(t *testing.T) { + r := &report.NodeReport{ + Vault: &report.VaultReport{ + ServiceActive: true, + Responsive: true, + Status: "healthy", + RestartCount: 5, + }, + } + alerts := checkNodeVault(r, "10.0.0.1") + if len(alerts) != 1 { + t.Fatalf("expected 1 alert, got %d", len(alerts)) + } + if alerts[0].Severity != AlertWarning { + t.Errorf("expected warning, got %s", alerts[0].Severity) + } +} + +func TestCheckNodeVault_healthy(t *testing.T) { + r := &report.NodeReport{ + Vault: &report.VaultReport{ + ServiceActive: true, + Responsive: true, + Status: "healthy", + Guardians: 5, + Healthy: 5, + Threshold: 3, + WriteQuorum: 4, + RestartCount: 0, + }, + } + alerts := checkNodeVault(r, "10.0.0.1") + if len(alerts) != 0 { + t.Errorf("expected 0 alerts for healthy vault, got %d", len(alerts)) + } +} diff --git a/core/pkg/cli/noderesolver/resolver.go b/core/pkg/cli/noderesolver/resolver.go new file mode 100644 index 0000000..0843c53 --- /dev/null +++ b/core/pkg/cli/noderesolver/resolver.go @@ -0,0 +1,161 @@ +// Package noderesolver provides unified node discovery for the orama CLI. +// +// It resolves operator-owned nodes by querying the network's gateway API +// (primary) or falling back to the legacy nodes.conf file. +package noderesolver + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/DeBrosOfficial/network/pkg/auth" + "github.com/DeBrosOfficial/network/pkg/cli" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" + "github.com/DeBrosOfficial/network/pkg/inspector" +) + +// httpClient is the shared HTTP client for API calls. +var httpClient = &http.Client{Timeout: 10 * time.Second} + +// ResolveNodes returns the operator's nodes for a given environment. +// It first tries the network API (GET /v1/operator/nodes), then falls +// back to nodes.conf if the API is unreachable or returns no results. +func ResolveNodes(env string) ([]inspector.Node, error) { + nodes, err := resolveFromNetwork(env) + if err == nil && len(nodes) > 0 { + return nodes, nil + } + + // Fallback to nodes.conf + confNodes, confErr := remotessh.LoadEnvNodes(env) + if confErr != nil { + if err != nil { + return nil, fmt.Errorf("network API: %w; nodes.conf: %v", err, confErr) + } + return nil, confErr + } + return confNodes, nil +} + +// ResolveNodesNetworkOnly queries only the network API without nodes.conf fallback. +func ResolveNodesNetworkOnly(env string) ([]inspector.Node, error) { + return resolveFromNetwork(env) +} + +// resolveFromNetwork queries the gateway API for operator-owned nodes. +func resolveFromNetwork(env string) ([]inspector.Node, error) { + // 1. Get gateway URL for the environment + gatewayURL, err := gatewayURLForEnv(env) + if err != nil { + return nil, fmt.Errorf("failed to resolve gateway URL: %w", err) + } + + // 2. Load stored credentials for this gateway + apiKey, err := loadAPIKey(gatewayURL) + if err != nil { + return nil, fmt.Errorf("no credentials for %s: %w (run 'orama auth login' first)", gatewayURL, err) + } + + return resolveFromNetworkWithURL(gatewayURL, apiKey, env) +} + +// resolveFromNetworkWithURL queries a specific gateway URL with an API key. +// Exported for testing. +func resolveFromNetworkWithURL(gatewayURL, apiKey, env string) ([]inspector.Node, error) { + endpoint := fmt.Sprintf("%s/v1/operator/nodes?env=%s", gatewayURL, url.QueryEscape(env)) + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("X-API-Key", apiKey) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to reach gateway: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("gateway returned HTTP %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Nodes []struct { + ID string `json:"id"` + IPAddress string `json:"ip_address"` + InternalIP string `json:"internal_ip"` + Environment string `json:"environment"` + Role string `json:"role"` + SSHUser string `json:"ssh_user"` + Status string `json:"status"` + } `json:"nodes"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + nodes := make([]inspector.Node, 0, len(result.Nodes)) + for _, n := range result.Nodes { + user := n.SSHUser + if user == "" { + user = "root" + } + // Sandbox nodes share a single SSH key; production nodes use per-host keys. + vaultTarget := fmt.Sprintf("%s/%s", n.IPAddress, user) + if n.Environment == "sandbox" { + vaultTarget = "sandbox/root" + } + nodes = append(nodes, inspector.Node{ + Environment: n.Environment, + User: user, + Host: n.IPAddress, + Role: n.Role, + VaultTarget: vaultTarget, + }) + } + + return nodes, nil +} + +// gatewayURLForEnv returns the gateway URL for a given environment name. +// If env is empty, uses the active environment. +func gatewayURLForEnv(env string) (string, error) { + if env == "" { + e, err := cli.GetActiveEnvironment() + if err != nil { + return "", err + } + return e.GatewayURL, nil + } + + e, err := cli.GetEnvironmentByName(env) + if err != nil { + return "", err + } + return e.GatewayURL, nil +} + +// loadAPIKey loads the stored API key for a gateway URL. +func loadAPIKey(gatewayURL string) (string, error) { + store, err := auth.LoadEnhancedCredentials() + if err != nil { + return "", fmt.Errorf("failed to load credentials: %w", err) + } + + creds := store.GetDefaultCredential(gatewayURL) + if creds == nil || creds.APIKey == "" { + return "", fmt.Errorf("no credentials found for %s", gatewayURL) + } + + return creds.APIKey, nil +} diff --git a/core/pkg/cli/noderesolver/resolver_test.go b/core/pkg/cli/noderesolver/resolver_test.go new file mode 100644 index 0000000..3750e87 --- /dev/null +++ b/core/pkg/cli/noderesolver/resolver_test.go @@ -0,0 +1,152 @@ +package noderesolver + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGatewayURLForEnv_knownEnv(t *testing.T) { + url, err := gatewayURLForEnv("devnet") + if err != nil { + t.Fatalf("gatewayURLForEnv(devnet): %v", err) + } + if url == "" { + t.Error("expected non-empty gateway URL for devnet") + } +} + +func TestGatewayURLForEnv_unknownEnv(t *testing.T) { + _, err := gatewayURLForEnv("nonexistent") + if err == nil { + t.Error("expected error for unknown environment") + } +} + +func TestResolveFromMockServer_happyPath(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/operator/nodes" { + http.Error(w, "not found", http.StatusNotFound) + return + } + if r.Header.Get("X-API-Key") != "test-key" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + env := r.URL.Query().Get("env") + resp := map[string]interface{}{ + "nodes": []map[string]string{ + {"id": "node-1", "ip_address": "1.2.3.4", "environment": env, "role": "nameserver", "ssh_user": "root", "status": "active"}, + {"id": "node-2", "ip_address": "5.6.7.8", "environment": env, "role": "node", "ssh_user": "ubuntu", "status": "active"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + nodes, err := resolveFromNetworkWithURL(server.URL, "test-key", "devnet") + if err != nil { + t.Fatalf("resolveFromNetworkWithURL: %v", err) + } + + if len(nodes) != 2 { + t.Fatalf("expected 2 nodes, got %d", len(nodes)) + } + + if nodes[0].Host != "1.2.3.4" { + t.Errorf("node 0 host = %q, want %q", nodes[0].Host, "1.2.3.4") + } + if nodes[0].Role != "nameserver" { + t.Errorf("node 0 role = %q, want %q", nodes[0].Role, "nameserver") + } + if nodes[0].VaultTarget != "1.2.3.4/root" { + t.Errorf("node 0 vault target = %q, want %q", nodes[0].VaultTarget, "1.2.3.4/root") + } + if nodes[0].Environment != "devnet" { + t.Errorf("node 0 environment = %q, want %q", nodes[0].Environment, "devnet") + } + if nodes[1].User != "ubuntu" { + t.Errorf("node 1 user = %q, want %q", nodes[1].User, "ubuntu") + } + if nodes[1].VaultTarget != "5.6.7.8/ubuntu" { + t.Errorf("node 1 vault target = %q, want %q", nodes[1].VaultTarget, "5.6.7.8/ubuntu") + } +} + +func TestResolveFromMockServer_emptySSHUser(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "nodes": []map[string]string{ + {"id": "node-1", "ip_address": "1.2.3.4", "environment": "devnet", "role": "node", "ssh_user": "", "status": "active"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + nodes, err := resolveFromNetworkWithURL(server.URL, "key", "devnet") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + if nodes[0].User != "root" { + t.Errorf("user = %q, want %q (default)", nodes[0].User, "root") + } + if nodes[0].VaultTarget != "1.2.3.4/root" { + t.Errorf("vault target = %q, want %q", nodes[0].VaultTarget, "1.2.3.4/root") + } +} + +func TestResolveFromMockServer_unauthorized(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + })) + defer server.Close() + + _, err := resolveFromNetworkWithURL(server.URL, "bad-key", "devnet") + if err == nil { + t.Error("expected error for unauthorized request") + } +} + +func TestResolveFromMockServer_emptyNodes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"nodes": []interface{}{}}) + })) + defer server.Close() + + nodes, err := resolveFromNetworkWithURL(server.URL, "key", "devnet") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(nodes) != 0 { + t.Errorf("expected 0 nodes, got %d", len(nodes)) + } +} + +func TestResolveFromMockServer_malformedJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`not json`)) + })) + defer server.Close() + + _, err := resolveFromNetworkWithURL(server.URL, "key", "devnet") + if err == nil { + t.Error("expected error for malformed JSON response") + } +} + +func TestResolveFromMockServer_serverDown(t *testing.T) { + _, err := resolveFromNetworkWithURL("http://127.0.0.1:1", "key", "devnet") + if err == nil { + t.Error("expected error for unreachable server") + } +} diff --git a/core/pkg/cli/production/clean/clean.go b/core/pkg/cli/production/clean/clean.go index 547a9a3..fe4b61f 100644 --- a/core/pkg/cli/production/clean/clean.go +++ b/core/pkg/cli/production/clean/clean.go @@ -133,7 +133,7 @@ func cleanNode(node inspector.Node, nuclear bool) error { %s # Stop services -for svc in caddy coredns orama-node orama-gateway orama-ipfs-cluster orama-ipfs orama-olric orama-anyone-relay orama-anyone-client; do +for svc in caddy coredns orama-node orama-gateway orama-ipfs-cluster orama-ipfs orama-olric orama-vault orama-anyone-relay orama-anyone-client; do systemctl stop "$svc" 2>/dev/null systemctl disable "$svc" 2>/dev/null done @@ -171,7 +171,7 @@ rm -f /tmp/orama-*.sh /tmp/network-source.tar.gz /tmp/orama-*.tar.gz # Nuclear: remove binaries if [ -n "$NUCLEAR" ]; then rm -f /usr/local/bin/orama /usr/local/bin/orama-node /usr/local/bin/gateway - rm -f /usr/local/bin/identity /usr/local/bin/sfu /usr/local/bin/turn + rm -f /usr/local/bin/identity /usr/local/bin/sfu /usr/local/bin/turn /usr/local/bin/orama-sni-router rm -f /usr/local/bin/olric-server /usr/local/bin/ipfs /usr/local/bin/ipfs-cluster-service rm -f /usr/local/bin/rqlited /usr/local/bin/coredns rm -f /usr/bin/caddy diff --git a/core/pkg/cli/production/install/flags.go b/core/pkg/cli/production/install/flags.go index 50b844e..d3a360d 100644 --- a/core/pkg/cli/production/install/flags.go +++ b/core/pkg/cli/production/install/flags.go @@ -43,6 +43,11 @@ type Flags struct { AnyoneFamily string // Comma-separated fingerprints of other relays you operate AnyoneBandwidth int // Percentage of VPS bandwidth for relay (default: 30, 0=unlimited) AnyoneAccounting int // Monthly data cap for relay in GB (0=unlimited) + + // Operator metadata (set by orama node setup, written to node.yaml for registration) + SSHUser string // SSH user for remote management + Environment string // Environment name (devnet, testnet, etc.) + OperatorWallet string // Operator wallet address } // ParseFlags parses install command flags @@ -90,6 +95,11 @@ func ParseFlags(args []string) (*Flags, error) { fs.IntVar(&flags.AnyoneBandwidth, "anyone-bandwidth", 30, "Limit relay to N% of VPS bandwidth (0=unlimited, runs speedtest)") fs.IntVar(&flags.AnyoneAccounting, "anyone-accounting", 0, "Monthly data cap for relay in GB (0=unlimited)") + // Operator metadata (set by orama node setup) + fs.StringVar(&flags.SSHUser, "ssh-user", "", "SSH user for remote management") + fs.StringVar(&flags.Environment, "environment", "", "Environment name (devnet, testnet, etc.)") + fs.StringVar(&flags.OperatorWallet, "operator-wallet", "", "Operator wallet address") + if err := fs.Parse(args); err != nil { if err == flag.ErrHelp { return nil, err diff --git a/core/pkg/cli/production/install/orchestrator.go b/core/pkg/cli/production/install/orchestrator.go index 04a4054..58f0f0d 100644 --- a/core/pkg/cli/production/install/orchestrator.go +++ b/core/pkg/cli/production/install/orchestrator.go @@ -68,6 +68,11 @@ func NewOrchestrator(flags *Flags) (*Orchestrator, error) { setup.SetAnyoneClient(true) } + // Set operator metadata (from orama node setup) + setup.SSHUser = flags.SSHUser + setup.Environment = flags.Environment + setup.OperatorWallet = flags.OperatorWallet + validator := NewValidator(flags, oramaDir) return &Orchestrator{ diff --git a/core/pkg/cli/production/lifecycle/restart.go b/core/pkg/cli/production/lifecycle/restart.go index 3560b1c..07e7e1d 100644 --- a/core/pkg/cli/production/lifecycle/restart.go +++ b/core/pkg/cli/production/lifecycle/restart.go @@ -53,6 +53,7 @@ func HandleRestartWithFlags(force bool) { {"orama-node"}, {"orama-olric"}, {"orama-ipfs-cluster", "orama-ipfs"}, + {"orama-vault"}, {"orama-anyone-relay", "orama-anyone-client"}, {"coredns", "caddy"}, } diff --git a/core/pkg/cli/production/lifecycle/stop.go b/core/pkg/cli/production/lifecycle/stop.go index 0e7f289..53433ad 100644 --- a/core/pkg/cli/production/lifecycle/stop.go +++ b/core/pkg/cli/production/lifecycle/stop.go @@ -55,8 +55,9 @@ func HandleStopWithFlags(force bool) { {"orama-node"}, // 1. Stop node (includes gateway + RQLite with leadership transfer) {"orama-olric"}, // 2. Stop cache {"orama-ipfs-cluster", "orama-ipfs"}, // 3. Stop storage - {"orama-anyone-relay", "orama-anyone-client"}, // 4. Stop privacy relay - {"coredns", "caddy"}, // 5. Stop DNS/TLS last + {"orama-vault"}, // 4. Stop vault + {"orama-anyone-relay", "orama-anyone-client"}, // 5. Stop privacy relay + {"coredns", "caddy"}, // 6. Stop DNS/TLS last } // Mask all services to immediately prevent Restart=always from reviving them. diff --git a/core/pkg/cli/production/report/processes.go b/core/pkg/cli/production/report/processes.go index bd5038d..1cc8243 100644 --- a/core/pkg/cli/production/report/processes.go +++ b/core/pkg/cli/production/report/processes.go @@ -89,6 +89,7 @@ func collectProcesses() *ProcessReport { var managedServiceUnits = []string{ "orama-node", "orama-olric", "orama-ipfs", "orama-ipfs-cluster", + "orama-vault", "orama-anyone-relay", "orama-anyone-client", "coredns", "caddy", "rqlited", } diff --git a/core/pkg/cli/production/report/report.go b/core/pkg/cli/production/report/report.go index 317a44b..2c72791 100644 --- a/core/pkg/cli/production/report/report.go +++ b/core/pkg/cli/production/report/report.go @@ -71,6 +71,10 @@ func Handle(jsonFlag bool, version string) error { rpt.IPFS = collectIPFS() }) + safeGo(&wg, "vault", func() { + rpt.Vault = collectVault() + }) + safeGo(&wg, "gateway", func() { rpt.Gateway = collectGateway() }) diff --git a/core/pkg/cli/production/report/services.go b/core/pkg/cli/production/report/services.go index 5138927..5939e28 100644 --- a/core/pkg/cli/production/report/services.go +++ b/core/pkg/cli/production/report/services.go @@ -13,6 +13,7 @@ var coreServices = []string{ "orama-olric", "orama-ipfs", "orama-ipfs-cluster", + "orama-vault", "orama-anyone-relay", "orama-anyone-client", "coredns", diff --git a/core/pkg/cli/production/report/types.go b/core/pkg/cli/production/report/types.go index 7607917..29f6df4 100644 --- a/core/pkg/cli/production/report/types.go +++ b/core/pkg/cli/production/report/types.go @@ -17,6 +17,7 @@ type NodeReport struct { RQLite *RQLiteReport `json:"rqlite,omitempty"` Olric *OlricReport `json:"olric,omitempty"` IPFS *IPFSReport `json:"ipfs,omitempty"` + Vault *VaultReport `json:"vault,omitempty"` Gateway *GatewayReport `json:"gateway,omitempty"` WireGuard *WireGuardReport `json:"wireguard,omitempty"` DNS *DNSReport `json:"dns,omitempty"` @@ -150,6 +151,21 @@ type IPFSReport struct { BootstrapEmpty bool `json:"bootstrap_empty"` } +// --- Vault --- + +type VaultReport struct { + ServiceActive bool `json:"service_active"` + Responsive bool `json:"responsive"` + Status string `json:"status,omitempty"` // "healthy", "degraded", "unavailable" + Guardians int `json:"guardians,omitempty"` + Healthy int `json:"healthy,omitempty"` + Threshold int `json:"threshold,omitempty"` + WriteQuorum int `json:"write_quorum,omitempty"` + ProcessMemMB int `json:"process_mem_mb"` + RestartCount int `json:"restart_count"` + LogErrors int `json:"log_errors_1h"` +} + // --- Gateway --- type GatewayReport struct { diff --git a/core/pkg/cli/production/report/vault.go b/core/pkg/cli/production/report/vault.go new file mode 100644 index 0000000..45e269f --- /dev/null +++ b/core/pkg/cli/production/report/vault.go @@ -0,0 +1,70 @@ +package report + +import ( + "context" + "encoding/json" + "strconv" + "strings" + "time" +) + +func collectVault() *VaultReport { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + r := &VaultReport{} + + // 1. Service active + if out, err := runCmd(ctx, "systemctl", "is-active", "orama-vault"); err == nil { + r.ServiceActive = strings.TrimSpace(out) == "active" + } + + // 2. Restart count + if out, err := runCmd(ctx, "systemctl", "show", "orama-vault", "--property=NRestarts"); err == nil { + if parts := strings.SplitN(out, "=", 2); len(parts) == 2 { + r.RestartCount, _ = strconv.Atoi(strings.TrimSpace(parts[1])) + } + } + + // 3. Process memory + if out, err := runCmd(ctx, "systemctl", "show", "orama-vault", "--property=MemoryCurrent"); err == nil { + if parts := strings.SplitN(out, "=", 2); len(parts) == 2 { + r.ProcessMemMB = parseMemoryMB(parts[1]) + } + } + + // 4. Log errors in last hour + if out, err := runCmd(ctx, "bash", "-c", + `journalctl -u orama-vault --no-pager -n 200 --since "1 hour ago" 2>/dev/null | grep -ciE "(error|ERR)" || echo 0`); err == nil { + r.LogErrors, _ = strconv.Atoi(strings.TrimSpace(out)) + } + + // 5. Query vault status via gateway (provides guardian health) + if body, err := httpGet(ctx, "http://localhost:6001/v1/vault/status"); err == nil { + var status struct { + Guardians int `json:"guardians"` + Healthy int `json:"healthy"` + Threshold int `json:"threshold"` + WriteQuorum int `json:"write_quorum"` + } + if json.Unmarshal(body, &status) == nil { + r.Responsive = true + r.Guardians = status.Guardians + r.Healthy = status.Healthy + r.Threshold = status.Threshold + r.WriteQuorum = status.WriteQuorum + } + } + + // 6. Query vault health status + if body, err := httpGet(ctx, "http://localhost:6001/v1/vault/health"); err == nil { + var health struct { + Status string `json:"status"` + } + if json.Unmarshal(body, &health) == nil { + r.Status = health.Status + } + } + + return r +} diff --git a/core/pkg/cli/production/setup/command.go b/core/pkg/cli/production/setup/command.go new file mode 100644 index 0000000..6c08aad --- /dev/null +++ b/core/pkg/cli/production/setup/command.go @@ -0,0 +1,369 @@ +// Package setup implements the "orama node setup" command — a single command +// to bootstrap a fresh VPS into a running Orama node. +// +// Flow: +// 1. Create SSH key in rootwallet vault for this node +// 2. Install the public key on the VPS (one-time password-based SSH) +// 3. Upload the binary archive +// 4. For genesis: run install without --join +// 5. For joining: request invite token via operator API, run install with --join +package setup + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/DeBrosOfficial/network/pkg/auth" + "github.com/DeBrosOfficial/network/pkg/cli" + "github.com/DeBrosOfficial/network/pkg/cli/remotessh" + "github.com/DeBrosOfficial/network/pkg/inspector" + "github.com/DeBrosOfficial/network/pkg/rwagent" +) + +// Options holds the flags for the setup command. +type Options struct { + IP string + Env string + Role string // "node" or "nameserver" + User string // SSH user (default: "root") + Password string // One-time password for initial SSH access + BaseDomain string + Gateway string // Gateway URL to use for invite tokens (overrides env config) + Genesis bool // If true, create a new cluster instead of joining + AnyoneRelay bool +} + +// Run executes the node setup. +func Run(opts Options) error { + if opts.IP == "" { + return fmt.Errorf("--ip is required") + } + if opts.User == "" { + opts.User = "root" + } + if opts.Role == "" { + opts.Role = "node" + } + + // 1. Ensure rootwallet agent is running + fmt.Println("Checking rootwallet agent...") + agentClient := rwagent.New(os.Getenv("RW_AGENT_SOCK")) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + status, err := agentClient.Status(ctx) + if err != nil { + return fmt.Errorf("rootwallet agent not reachable: %w (is the desktop app running?)", err) + } + if status.Locked { + return fmt.Errorf("rootwallet agent is locked — unlock it in the desktop app first") + } + + // 2. Get operator wallet address + addrData, err := agentClient.GetAddress(ctx, "evm") + if err != nil { + return fmt.Errorf("failed to get wallet address: %w", err) + } + fmt.Printf(" Wallet: %s\n", addrData.Address) + + // 3. Create SSH key in rootwallet vault for this node + vaultTarget := fmt.Sprintf("%s/%s", opts.IP, opts.User) + fmt.Printf(" Setting up SSH key for %s...\n", vaultTarget) + + if err := remotessh.EnsureVaultEntry(vaultTarget); err != nil { + return fmt.Errorf("failed to create SSH key in vault: %w", err) + } + + pubKey, err := remotessh.ResolveVaultPublicKey(vaultTarget) + if err != nil { + return fmt.Errorf("failed to get public key: %w", err) + } + + // 4. Install the public key on the VPS via password SSH + if opts.Password != "" { + fmt.Printf(" Installing SSH key on %s...\n", opts.IP) + if err := installPublicKey(opts.IP, opts.User, opts.Password, pubKey); err != nil { + return fmt.Errorf("failed to install SSH key: %w", err) + } + fmt.Println(" SSH key installed") + } else { + fmt.Println(" No --password provided, assuming SSH key is already installed") + } + + // 5. Test SSH with rootwallet key + fmt.Println(" Testing SSH connection...") + node := inspector.Node{ + Host: opts.IP, + User: opts.User, + VaultTarget: vaultTarget, + Environment: opts.Env, + Role: opts.Role, + } + nodes := []inspector.Node{node} + cleanup, err := remotessh.PrepareNodeKeys(nodes) + if err != nil { + return fmt.Errorf("failed to prepare SSH key: %w", err) + } + defer cleanup() + node = nodes[0] // SSHKey is now set + + testResult := inspector.RunSSH(context.Background(), node, "echo ok") + if !testResult.OK() { + return fmt.Errorf("SSH test failed: %s", testResult.Stderr) + } + fmt.Println(" SSH connection OK") + + // 6. Check if binary archive needs uploading + if needsArchiveUpload(node) { + archivePath := findNewestArchive() + if archivePath == "" { + return fmt.Errorf("no binary archive found in /tmp/ (run `orama build` first)") + } + fmt.Printf(" Uploading archive (%s)...\n", filepath.Base(archivePath)) + if err := remotessh.UploadFile(node, archivePath, "/tmp/archive.tar.gz"); err != nil { + return fmt.Errorf("failed to upload archive: %w", err) + } + extractCmd := "sudo bash -c 'mkdir -p /opt/orama && tar xzf /tmp/archive.tar.gz -C /opt/orama && rm -f /tmp/archive.tar.gz'" + if err := remotessh.RunSSHStreaming(node, extractCmd); err != nil { + return fmt.Errorf("failed to extract archive: %w", err) + } + fmt.Println(" Archive extracted") + } else { + fmt.Println(" Binary already present on node") + } + + // 7. Build the install command + installCmd, err := buildInstallCommand(opts, node, agentClient) + if err != nil { + return fmt.Errorf("failed to build install command: %w", err) + } + + fmt.Printf("\n Running: %s\n\n", installCmd) + + // 8. Run the install + if err := remotessh.RunSSHStreaming(node, installCmd); err != nil { + return fmt.Errorf("install failed: %w", err) + } + + // 9. After genesis install, update the environment gateway URL to this node's IP. + // This allows subsequent `node setup` calls to find the gateway automatically. + if opts.Genesis && opts.Env != "" { + gatewayURL := fmt.Sprintf("http://%s", opts.IP) + desc := fmt.Sprintf("%s (genesis: %s)", opts.Env, opts.IP) + if err := cli.AddEnvironment(opts.Env, gatewayURL, desc); err != nil { + fmt.Fprintf(os.Stderr, " Warning: failed to update environment: %v\n", err) + } else { + if err := cli.SwitchEnvironment(opts.Env); err != nil { + fmt.Fprintf(os.Stderr, " Warning: failed to switch environment: %v\n", err) + } + fmt.Printf(" Environment %q updated: gateway → %s\n", opts.Env, gatewayURL) + fmt.Printf("\n To join more nodes, first authenticate:\n") + fmt.Printf(" orama auth login\n") + fmt.Printf(" Then:\n") + fmt.Printf(" orama node setup --ip --password '' --env %s --base-domain %s\n", opts.Env, opts.BaseDomain) + } + } + + fmt.Printf("\n Node %s setup complete!\n", opts.IP) + return nil +} + +// installPublicKey installs an SSH public key on a VPS using password authentication. +func installPublicKey(ip, user, password, pubKey string) error { + sshpassBin, err := findBinary("sshpass") + if err != nil { + return fmt.Errorf("sshpass is required for password-based SSH key installation: %w", err) + } + + // Ensure .ssh directory exists and install the key + cmd := fmt.Sprintf( + `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '%s' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && echo 'key installed'`, + strings.TrimSpace(pubKey), + ) + + args := []string{ + "-p", password, + "ssh", + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=10", + "-o", "PreferredAuthentications=password", + "-o", "PubkeyAuthentication=no", + fmt.Sprintf("%s@%s", user, ip), + cmd, + } + + out, err := runCommand(sshpassBin, args...) + if err != nil { + return fmt.Errorf("sshpass failed: %w (%s)", err, out) + } + if !strings.Contains(out, "key installed") { + return fmt.Errorf("unexpected output: %s", out) + } + return nil +} + +// buildInstallCommand constructs the `sudo orama node install` command. +func buildInstallCommand(opts Options, node inspector.Node, agentClient *rwagent.Client) (string, error) { + parts := []string{"sudo /opt/orama/bin/orama node install"} + parts = append(parts, "--vps-ip", opts.IP) + + if opts.BaseDomain != "" { + parts = append(parts, "--base-domain", opts.BaseDomain) + } + + if strings.HasPrefix(opts.Role, "nameserver") { + parts = append(parts, "--nameserver") + if opts.BaseDomain != "" { + parts = append(parts, "--domain", opts.BaseDomain) + } + } + + if opts.AnyoneRelay { + parts = append(parts, "--anyone-relay") + } else { + parts = append(parts, "--anyone-client") + } + + // Pass operator metadata so the node registers with correct values + if opts.User != "" { + parts = append(parts, "--ssh-user", opts.User) + } + if opts.Env != "" { + parts = append(parts, "--environment", opts.Env) + } + + // Get wallet address for operator tagging + ctx := context.Background() + if addrData, err := agentClient.GetAddress(ctx, "evm"); err == nil && addrData.Address != "" { + parts = append(parts, "--operator-wallet", addrData.Address) + } + + if !opts.Genesis { + // Determine gateway URL for invite token request + gatewayURL := opts.Gateway + if gatewayURL == "" { + env := opts.Env + if env == "" { + active, err := cli.GetActiveEnvironment() + if err != nil { + return "", fmt.Errorf("failed to get active environment: %w", err) + } + env = active.Name + } + envConfig, err := cli.GetEnvironmentByName(env) + if err != nil { + return "", fmt.Errorf("environment %q not found (use --gateway to specify directly): %w", env, err) + } + gatewayURL = envConfig.GatewayURL + } + + // Request invite token via operator API + token, err := requestInviteToken(gatewayURL) + if err != nil { + return "", fmt.Errorf("failed to get invite token: %w", err) + } + + parts = append(parts, "--join", gatewayURL, "--token", token) + } + + return strings.Join(parts, " "), nil +} + +// requestInviteToken calls POST /v1/operator/invite to get an invite token. +func requestInviteToken(gatewayURL string) (string, error) { + store, err := auth.LoadEnhancedCredentials() + if err != nil { + return "", fmt.Errorf("failed to load credentials: %w", err) + } + creds := store.GetDefaultCredential(gatewayURL) + if creds == nil || creds.APIKey == "" { + return "", fmt.Errorf("no credentials for %s — run 'orama auth login' first", gatewayURL) + } + + body, _ := json.Marshal(map[string]int{"expiry_minutes": 60}) + req, err := http.NewRequest(http.MethodPost, gatewayURL+"/v1/operator/invite", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", creds.APIKey) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + var result struct { + Token string `json:"token"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + if result.Token == "" { + return "", fmt.Errorf("empty token in response") + } + return result.Token, nil +} + +// needsArchiveUpload checks if the node already has the orama binary. +func needsArchiveUpload(node inspector.Node) bool { + result := inspector.RunSSH(context.Background(), node, "/opt/orama/bin/orama version 2>/dev/null") + return !result.OK() +} + +// findNewestArchive finds the newest orama binary archive in /tmp/. +func findNewestArchive() string { + matches, _ := filepath.Glob("/tmp/orama-*-linux-*.tar.gz") + if len(matches) == 0 { + return "" + } + sort.Slice(matches, func(i, j int) bool { + fi, _ := os.Stat(matches[i]) + fj, _ := os.Stat(matches[j]) + if fi == nil || fj == nil { + return false + } + return fi.ModTime().After(fj.ModTime()) + }) + return matches[0] +} + +func findBinary(name string) (string, error) { + paths := []string{ + "/opt/homebrew/bin/" + name, + "/usr/local/bin/" + name, + "/usr/bin/" + name, + } + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + return "", fmt.Errorf("%s not found", name) +} + +func runCommand(bin string, args ...string) (string, error) { + cmd := &exec.Cmd{ + Path: bin, + Args: append([]string{bin}, args...), + } + out, err := cmd.CombinedOutput() + return string(out), err +} diff --git a/core/pkg/cli/production/status/command.go b/core/pkg/cli/production/status/command.go index 4120693..c7ea512 100644 --- a/core/pkg/cli/production/status/command.go +++ b/core/pkg/cli/production/status/command.go @@ -17,6 +17,7 @@ func Handle() { "orama-ipfs-cluster", // Note: RQLite is managed by node process, not as separate service "orama-olric", + "orama-vault", "orama-node", // Note: gateway is embedded in orama-node, no separate service } @@ -26,6 +27,7 @@ func Handle() { "orama-ipfs": "IPFS Daemon", "orama-ipfs-cluster": "IPFS Cluster", "orama-olric": "Olric Cache Server", + "orama-vault": "Vault Guardian", "orama-node": "Orama Node (includes RQLite + Gateway)", } diff --git a/core/pkg/cli/production/upgrade/orchestrator.go b/core/pkg/cli/production/upgrade/orchestrator.go index 8c20bdb..38f3319 100644 --- a/core/pkg/cli/production/upgrade/orchestrator.go +++ b/core/pkg/cli/production/upgrade/orchestrator.go @@ -376,6 +376,7 @@ func (o *Orchestrator) stopServices() error { "orama-ipfs-cluster.service", // Depends on IPFS "orama-ipfs.service", // Base IPFS "orama-olric.service", // Independent + "orama-vault.service", // Vault guardian "orama-anyone-client.service", // Client mode "orama-anyone-relay.service", // Relay mode } @@ -683,6 +684,7 @@ func (o *Orchestrator) restartServices() error { "orama-olric", // Distributed cache "orama-ipfs", // IPFS daemon "orama-ipfs-cluster", // IPFS cluster + "orama-vault", // Vault guardian "orama-gateway", // Gateway (legacy) "coredns", // DNS server "caddy", // Reverse proxy diff --git a/core/pkg/cli/remotessh/ssh.go b/core/pkg/cli/remotessh/ssh.go index 3ce5157..73bcd4f 100644 --- a/core/pkg/cli/remotessh/ssh.go +++ b/core/pkg/cli/remotessh/ssh.go @@ -42,7 +42,7 @@ func UploadFile(node inspector.Node, localPath, remotePath string, opts ...SSHOp dest := fmt.Sprintf("%s@%s:%s", node.User, node.Host, remotePath) - args := []string{"-o", "ConnectTimeout=10", "-i", node.SSHKey} + args := []string{"-o", "ConnectTimeout=10", "-o", "IdentitiesOnly=yes", "-i", node.SSHKey} if cfg.noHostKeyCheck { args = append([]string{"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"}, args...) } else { @@ -73,7 +73,7 @@ func RunSSHStreaming(node inspector.Node, command string, opts ...SSHOption) err o(&cfg) } - args := []string{"-o", "ConnectTimeout=10", "-i", node.SSHKey} + args := []string{"-o", "ConnectTimeout=10", "-o", "IdentitiesOnly=yes", "-i", node.SSHKey} if cfg.noHostKeyCheck { args = append([]string{"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"}, args...) } else { diff --git a/core/pkg/cli/sandbox/create.go b/core/pkg/cli/sandbox/create.go index 36e9bc3..747c150 100644 --- a/core/pkg/cli/sandbox/create.go +++ b/core/pkg/cli/sandbox/create.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/DeBrosOfficial/network/pkg/cli" "github.com/DeBrosOfficial/network/pkg/cli/remotessh" "github.com/DeBrosOfficial/network/pkg/inspector" "github.com/DeBrosOfficial/network/pkg/rwagent" @@ -144,6 +145,18 @@ func Create(name string) error { return fmt.Errorf("save final state: %w", err) } + // Register sandbox as an environment and switch to it + gatewayURL := "https://" + cfg.Domain + desc := fmt.Sprintf("Sandbox cluster: %s (%s)", state.Name, cfg.Domain) + if err := cli.AddEnvironment("sandbox", gatewayURL, desc); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to register sandbox environment: %v\n", err) + } else if err := cli.SwitchEnvironment("sandbox"); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to switch to sandbox environment: %v\n", err) + } + + // Tag all nodes with operator wallet for unified node management + registerNodesWithOperator(state, sshKeyPath) + printCreateSummary(cfg, state) return nil } @@ -633,6 +646,36 @@ func printCreateSummary(cfg *Config, state *SandboxState) { fmt.Println("Destroy: orama sandbox destroy") } +// registerNodesWithOperator tags all sandbox nodes with the operator's wallet +// via a direct RQLite UPDATE on the genesis node. This enables `orama nodes` +// to discover sandbox nodes alongside production nodes. +func registerNodesWithOperator(state *SandboxState, sshKeyPath string) { + client := rwagent.New(os.Getenv("RW_AGENT_SOCK")) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + addrData, err := client.GetAddress(ctx, "evm") + if err != nil || addrData == nil || addrData.Address == "" { + fmt.Fprintf(os.Stderr, "Warning: could not get operator wallet, nodes not tagged: %v\n", err) + return + } + wallet := addrData.Address + + if len(state.Servers) == 0 { + return + } + genesis := state.Servers[0] + + node := inspector.Node{User: "root", Host: genesis.IP, SSHKey: sshKeyPath} + // Use RQLite's parameterized query to avoid any injection risk. + // The JSON payload has the wallet as a parameter, not interpolated into SQL. + payload := fmt.Sprintf(`[["UPDATE dns_nodes SET operator_wallet = ?, environment = 'sandbox' WHERE operator_wallet IS NULL OR operator_wallet = ''", %q]]`, wallet) + cmd := fmt.Sprintf(`curl -sf -X POST http://localhost:5001/db/execute -H 'Content-Type: application/json' -d '%s'`, payload) + if _, err := runSSHOutput(node, cmd); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to tag nodes with operator wallet: %v\n", err) + } +} + // cleanupFailedCreate deletes any servers that were created during a failed provision. func cleanupFailedCreate(client *HetznerClient, state *SandboxState) { if len(state.Servers) == 0 { diff --git a/core/pkg/cli/sandbox/destroy.go b/core/pkg/cli/sandbox/destroy.go index b532a18..8e7ae3d 100644 --- a/core/pkg/cli/sandbox/destroy.go +++ b/core/pkg/cli/sandbox/destroy.go @@ -4,8 +4,11 @@ import ( "bufio" "fmt" "os" + "os/exec" "strings" "sync" + + "github.com/DeBrosOfficial/network/pkg/cli" ) // Destroy tears down a sandbox cluster. @@ -100,10 +103,30 @@ func Destroy(name string, force bool) error { return fmt.Errorf("delete state: %w", err) } + // Remove sandbox environment entry, fall back to devnet + if err := cli.RemoveEnvironment("sandbox"); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to remove sandbox environment: %v\n", err) + } + + // Clean up SSH known_hosts entries for destroyed server IPs. + // This prevents "REMOTE HOST IDENTIFICATION HAS CHANGED" errors + // when the same IPs are reused by a new sandbox. + cleanupKnownHosts(state) + fmt.Printf("\nSandbox %q destroyed (%d servers deleted)\n", state.Name, len(state.Servers)) return nil } +// cleanupKnownHosts removes SSH known_hosts entries for all sandbox server IPs. +func cleanupKnownHosts(state *SandboxState) { + for _, srv := range state.Servers { + cmd := exec.Command("ssh-keygen", "-R", srv.IP) + cmd.Stdout = nil + cmd.Stderr = nil + cmd.Run() // best-effort, ignore errors + } +} + // resolveSandbox finds a sandbox by name or returns the active one. func resolveSandbox(name string) (*SandboxState, error) { if name != "" { diff --git a/core/pkg/cli/utils/systemd.go b/core/pkg/cli/utils/systemd.go index b4a6ffb..2869f33 100644 --- a/core/pkg/cli/utils/systemd.go +++ b/core/pkg/cli/utils/systemd.go @@ -162,6 +162,7 @@ func GetProductionServices() []string { "orama-olric", "orama-ipfs-cluster", "orama-ipfs", + "orama-vault", "orama-anyone-client", "orama-anyone-relay", } diff --git a/core/pkg/client/interface.go b/core/pkg/client/interface.go index 2c7e40b..1fff4c9 100644 --- a/core/pkg/client/interface.go +++ b/core/pkg/client/interface.go @@ -47,10 +47,29 @@ type DatabaseClient interface { type PubSubClient interface { Subscribe(ctx context.Context, topic string, handler MessageHandler) error Publish(ctx context.Context, topic string, data []byte) error + // PublishBatch publishes multiple messages in parallel, one per topic. + // See pubsub.Manager.PublishBatch for semantics (fail-fast vs. best-effort). + PublishBatch(ctx context.Context, msgs []TopicMessage, opts PublishBatchOptions) error + // PublishSame sends the same payload to every topic in parallel. + PublishSame(ctx context.Context, topics []string, data []byte, opts PublishBatchOptions) error Unsubscribe(ctx context.Context, topic string) error ListTopics(ctx context.Context) ([]string, error) } +// TopicMessage is one entry in a batch publish. +// Mirrors pubsub.TopicMessage to avoid forcing client callers to import pkg/pubsub. +type TopicMessage struct { + Topic string + Data []byte +} + +// PublishBatchOptions controls batch publish behavior. +// Mirrors pubsub.PublishBatchOptions. +type PublishBatchOptions struct { + BestEffort bool + MaxConcurrency int +} + // NetworkInfo provides network status and peer information type NetworkInfo interface { GetPeers(ctx context.Context) ([]PeerInfo, error) diff --git a/core/pkg/client/pubsub_bridge.go b/core/pkg/client/pubsub_bridge.go index 653301e..79780b0 100644 --- a/core/pkg/client/pubsub_bridge.go +++ b/core/pkg/client/pubsub_bridge.go @@ -4,13 +4,13 @@ import ( "context" "fmt" - "github.com/DeBrosOfficial/network/pkg/pubsub" + pkgpubsub "github.com/DeBrosOfficial/network/pkg/pubsub" ) // pubSubBridge bridges between our PubSubClient interface and the pubsub package type pubSubBridge struct { client *Client - adapter *pubsub.ClientAdapter + adapter *pkgpubsub.ClientAdapter } func (p *pubSubBridge) Subscribe(ctx context.Context, topic string, handler MessageHandler) error { @@ -31,6 +31,26 @@ func (p *pubSubBridge) Publish(ctx context.Context, topic string, data []byte) e return p.adapter.Publish(ctx, topic, data) } +func (p *pubSubBridge) PublishBatch(ctx context.Context, msgs []TopicMessage, opts PublishBatchOptions) error { + if err := p.client.requireAccess(ctx); err != nil { + return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) + } + pkgMsgs := make([]pkgpubsub.TopicMessage, len(msgs)) + for i, m := range msgs { + pkgMsgs[i] = pkgpubsub.TopicMessage{Topic: m.Topic, Data: m.Data} + } + pkgOpts := pkgpubsub.PublishBatchOptions{BestEffort: opts.BestEffort, MaxConcurrency: opts.MaxConcurrency} + return p.adapter.PublishBatch(ctx, pkgMsgs, pkgOpts) +} + +func (p *pubSubBridge) PublishSame(ctx context.Context, topics []string, data []byte, opts PublishBatchOptions) error { + if err := p.client.requireAccess(ctx); err != nil { + return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) + } + pkgOpts := pkgpubsub.PublishBatchOptions{BestEffort: opts.BestEffort, MaxConcurrency: opts.MaxConcurrency} + return p.adapter.PublishSame(ctx, topics, data, pkgOpts) +} + func (p *pubSubBridge) Unsubscribe(ctx context.Context, topic string) error { if err := p.client.requireAccess(ctx); err != nil { return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err) diff --git a/core/pkg/config/node_config.go b/core/pkg/config/node_config.go index a23ffcc..ab070c9 100644 --- a/core/pkg/config/node_config.go +++ b/core/pkg/config/node_config.go @@ -7,4 +7,7 @@ type NodeConfig struct { DataDir string `yaml:"data_dir"` // Data directory MaxConnections int `yaml:"max_connections"` // Maximum peer connections Domain string `yaml:"domain"` // Domain for this node (e.g., node-1.orama.network) + SSHUser string `yaml:"ssh_user,omitempty"` // SSH user for remote management + Environment string `yaml:"environment,omitempty"` // Environment name (devnet, testnet, etc.) + OperatorWallet string `yaml:"operator_wallet,omitempty"` // Operator wallet address } diff --git a/core/pkg/deployments/home_node_test.go b/core/pkg/deployments/home_node_test.go index 8b63ef6..2ee9d97 100644 --- a/core/pkg/deployments/home_node_test.go +++ b/core/pkg/deployments/home_node_test.go @@ -181,6 +181,10 @@ func (m *mockHomeNodeDB) Tx(ctx context.Context, fn func(tx rqlite.Tx) error) er return m.mockRQLiteClient.Tx(ctx, fn) } +func (m *mockHomeNodeDB) Batch(ctx context.Context, ops []rqlite.BatchOp) (*rqlite.BatchResult, error) { + return m.mockRQLiteClient.Batch(ctx, ops) +} + func (m *mockHomeNodeDB) addDeployment(nodeID, deploymentID, status string) { m.deployments[nodeID] = append(m.deployments[nodeID], deploymentData{ id: deploymentID, diff --git a/core/pkg/deployments/port_allocator_test.go b/core/pkg/deployments/port_allocator_test.go index 89d9f23..674130e 100644 --- a/core/pkg/deployments/port_allocator_test.go +++ b/core/pkg/deployments/port_allocator_test.go @@ -149,6 +149,15 @@ func (m *mockRQLiteClient) Tx(ctx context.Context, fn func(tx rqlite.Tx) error) return nil } +func (m *mockRQLiteClient) Batch(ctx context.Context, ops []rqlite.BatchOp) (*rqlite.BatchResult, error) { + return &rqlite.BatchResult{Committed: true, Results: make([]rqlite.OpResult, len(ops))}, nil +} + +func (m *mockRQLiteClient) BatchWithSeq(ctx context.Context, namespace string, ops []rqlite.BatchOp) (*rqlite.BatchResult, int64, error) { + res, err := m.Batch(ctx, ops) + return res, 1, err +} + func TestPortAllocator_AllocatePort(t *testing.T) { logger := zap.NewNop() mockDB := newMockRQLiteClient() diff --git a/core/pkg/environments/production/config.go b/core/pkg/environments/production/config.go index cb80560..2eaa530 100644 --- a/core/pkg/environments/production/config.go +++ b/core/pkg/environments/production/config.go @@ -20,7 +20,10 @@ import ( // ConfigGenerator manages generation of node, gateway, and service configs type ConfigGenerator struct { - oramaDir string + oramaDir string + SSHUser string // Operator metadata + Environment string + OperatorWallet string } // NewConfigGenerator creates a new config generator @@ -192,6 +195,11 @@ func (cg *ConfigGenerator) GenerateNodeConfig(peerAddresses []string, vpsIP stri // HTTPS is still used for client-facing gateway traffic via autocert // TLS can be enabled manually later if needed for inter-node encryption + // Operator metadata (set by orama node setup via --ssh-user, --environment, --operator-wallet) + data.SSHUser = cg.SSHUser + data.Environment = cg.Environment + data.OperatorWallet = cg.OperatorWallet + return templates.RenderNodeConfig(data) } diff --git a/core/pkg/environments/production/installers/caddy.go b/core/pkg/environments/production/installers/caddy.go index 5aad389..4e29775 100644 --- a/core/pkg/environments/production/installers/caddy.go +++ b/core/pkg/environments/production/installers/caddy.go @@ -390,7 +390,17 @@ func (ci *CaddyInstaller) generateCaddyfile(domain, email, acmeEndpoint, baseDom sb.WriteString(fmt.Sprintf("\n%s {\n%s\n reverse_proxy localhost:6001\n}\n", baseDomain, tlsBlock)) } - // HTTP fallback (handles plain HTTP and ACME challenges) + // HTTP blocks — serve traffic over plain HTTP so the gateway is reachable + // even when TLS certificates are unavailable (e.g., Let's Encrypt rate limits). + // Without these, Caddy auto-redirects HTTP→HTTPS for the named domain blocks above. + sb.WriteString(fmt.Sprintf("\nhttp://*.%s {\n reverse_proxy localhost:6001\n}\n", domain)) + sb.WriteString(fmt.Sprintf("\nhttp://%s {\n reverse_proxy localhost:6001\n}\n", domain)) + if baseDomain != "" && baseDomain != domain { + sb.WriteString(fmt.Sprintf("\nhttp://*.%s {\n reverse_proxy localhost:6001\n}\n", baseDomain)) + sb.WriteString(fmt.Sprintf("\nhttp://%s {\n reverse_proxy localhost:6001\n}\n", baseDomain)) + } + + // HTTP catch-all fallback (handles remaining plain HTTP traffic) sb.WriteString("\n:80 {\n reverse_proxy localhost:6001\n}\n") return sb.String() diff --git a/core/pkg/environments/production/orchestrator.go b/core/pkg/environments/production/orchestrator.go index 7458c75..4a3ace8 100644 --- a/core/pkg/environments/production/orchestrator.go +++ b/core/pkg/environments/production/orchestrator.go @@ -53,6 +53,11 @@ type ProductionSetup struct { serviceController *SystemdController binaryInstaller *BinaryInstaller NodePeerID string // Captured during Phase3 for later display + + // Operator metadata (from --ssh-user, --environment, --operator-wallet flags) + SSHUser string + Environment string + OperatorWallet string } // ReadBranchPreference reads the stored branch preference from disk @@ -599,6 +604,11 @@ func (ps *ProductionSetup) Phase4GenerateConfigs(peerAddresses []string, vpsIP s ps.logf("Phase 4: Generating configurations...") } + // Propagate operator metadata to config generator + ps.configGenerator.SSHUser = ps.SSHUser + ps.configGenerator.Environment = ps.Environment + ps.configGenerator.OperatorWallet = ps.OperatorWallet + // Node config (unified architecture) nodeConfig, err := ps.configGenerator.GenerateNodeConfig(peerAddresses, vpsIP, joinAddress, domain, baseDomain, enableHTTPS) if err != nil { diff --git a/core/pkg/environments/production/provisioner.go b/core/pkg/environments/production/provisioner.go index 15d8741..0a88537 100644 --- a/core/pkg/environments/production/provisioner.go +++ b/core/pkg/environments/production/provisioner.go @@ -86,31 +86,44 @@ func (fp *FilesystemProvisioner) EnsureDirectoryStructure() error { // EnsureOramaUser creates the 'orama' system user and group for running services. // Sets ownership of the orama data directory to the new user. func (fp *FilesystemProvisioner) EnsureOramaUser() error { - // Check if user already exists - if err := exec.Command("id", "orama").Run(); err == nil { - return nil // user already exists - } - - // Create system user with no login shell and home at /opt/orama - cmd := exec.Command("useradd", "--system", "--no-create-home", - "--home-dir", fp.oramaHome, "--shell", "/usr/sbin/nologin", "orama") - if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("failed to create orama user: %w\n%s", err, string(output)) - } - - // Set ownership of orama directories - chown := exec.Command("chown", "-R", "orama:orama", fp.oramaDir) - if output, err := chown.CombinedOutput(); err != nil { - return fmt.Errorf("failed to chown %s: %w\n%s", fp.oramaDir, err, string(output)) - } - - // Also chown the bin directory - binDir := filepath.Join(fp.oramaHome, "bin") - if _, err := os.Stat(binDir); err == nil { - chown = exec.Command("chown", "-R", "orama:orama", binDir) - if output, err := chown.CombinedOutput(); err != nil { - return fmt.Errorf("failed to chown %s: %w\n%s", binDir, err, string(output)) + // Check if user already exists; create if not + if err := exec.Command("id", "orama").Run(); err != nil { + cmd := exec.Command("useradd", "--system", "--no-create-home", + "--home-dir", fp.oramaHome, "--shell", "/usr/sbin/nologin", "orama") + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create orama user: %w\n%s", err, string(output)) } + + // Set ownership of orama directories (only on first create) + chown := exec.Command("chown", "-R", "orama:orama", fp.oramaDir) + if output, err := chown.CombinedOutput(); err != nil { + return fmt.Errorf("failed to chown %s: %w\n%s", fp.oramaDir, err, string(output)) + } + + binDir := filepath.Join(fp.oramaHome, "bin") + if _, err := os.Stat(binDir); err == nil { + chown = exec.Command("chown", "-R", "orama:orama", binDir) + if output, err := chown.CombinedOutput(); err != nil { + return fmt.Errorf("failed to chown %s: %w\n%s", binDir, err, string(output)) + } + } + } + + // Always ensure the sudoers rule is up-to-date (handles upgrades too). + // Resolve systemctl path to avoid hardcoding /bin vs /usr/bin. + systemctlPath, err := exec.LookPath("systemctl") + if err != nil { + systemctlPath = "/bin/systemctl" // fallback + } + + // Grant orama user permission to manage namespace and deployment services. + sudoersRule := fmt.Sprintf( + "orama ALL=(root) NOPASSWD: %[1]s start orama-namespace-*, %[1]s stop orama-namespace-*, %[1]s enable orama-namespace-*, %[1]s disable orama-namespace-*, %[1]s restart orama-namespace-*, %[1]s start orama-deploy-*, %[1]s stop orama-deploy-*, %[1]s enable orama-deploy-*, %[1]s disable orama-deploy-*, %[1]s restart orama-deploy-*, %[1]s daemon-reload\n", + systemctlPath, + ) + sudoersPath := "/etc/sudoers.d/orama-namespaces" + if err := os.WriteFile(sudoersPath, []byte(sudoersRule), 0440); err != nil { + return fmt.Errorf("failed to write sudoers rule: %w", err) } return nil diff --git a/core/pkg/environments/production/services.go b/core/pkg/environments/production/services.go index 4101e0b..2e8728b 100644 --- a/core/pkg/environments/production/services.go +++ b/core/pkg/environments/production/services.go @@ -19,6 +19,18 @@ ProtectKernelTunables=yes ProtectKernelModules=yes RestrictNamespaces=yes` +// oramaNodeHardening is like oramaServiceHardening but WITHOUT NoNewPrivileges. +// The node process (which includes the gateway) needs to use sudo to manage +// namespace systemd services. NoNewPrivileges prevents sudo from working. +const oramaNodeHardening = `User=orama +Group=orama +ProtectSystem=strict +ProtectHome=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +RestrictNamespaces=yes` + // SystemdServiceGenerator generates systemd unit files type SystemdServiceGenerator struct { oramaHome string @@ -233,7 +245,7 @@ OOMScoreAdjust=-500 [Install] WantedBy=multi-user.target -`, ssg.oramaHome, ssg.oramaDir, configFile, logFile, oramaServiceHardening) +`, ssg.oramaHome, ssg.oramaDir, configFile, logFile, oramaNodeHardening) } // GenerateVaultService generates the Orama Vault Guardian systemd unit. diff --git a/core/pkg/environments/templates/node.yaml b/core/pkg/environments/templates/node.yaml index e44e9da..8559e0f 100644 --- a/core/pkg/environments/templates/node.yaml +++ b/core/pkg/environments/templates/node.yaml @@ -5,6 +5,15 @@ node: data_dir: "{{.DataDir}}" max_connections: 50 domain: "{{.Domain}}" +{{- if .SSHUser}} + ssh_user: "{{.SSHUser}}" +{{- end}} +{{- if .Environment}} + environment: "{{.Environment}}" +{{- end}} +{{- if .OperatorWallet}} + operator_wallet: "{{.OperatorWallet}}" +{{- end}} database: data_dir: "{{.DataDir}}/rqlite" diff --git a/core/pkg/environments/templates/render.go b/core/pkg/environments/templates/render.go index d867955..135085e 100644 --- a/core/pkg/environments/templates/render.go +++ b/core/pkg/environments/templates/render.go @@ -41,6 +41,11 @@ type NodeConfigData struct { NodeKey string // Path to X.509 private key for node-to-node communication NodeCACert string // Path to CA certificate (optional) NodeNoVerify bool // Skip certificate verification (for self-signed certs) + + // Operator metadata — written to dns_nodes during registration + SSHUser string // SSH user for remote management + Environment string // Environment name (devnet, testnet, etc.) + OperatorWallet string // Operator wallet address } // GatewayConfigData holds parameters for gateway.yaml rendering diff --git a/core/pkg/gateway/auth/jwt.go b/core/pkg/gateway/auth/jwt.go index 7891c3b..4e79fd1 100644 --- a/core/pkg/gateway/auth/jwt.go +++ b/core/pkg/gateway/auth/jwt.go @@ -73,6 +73,10 @@ type JWTClaims struct { Nbf int64 `json:"nbf"` Exp int64 `json:"exp"` Namespace string `json:"namespace"` + // Custom holds app-defined claims (e.g. tier, subscription state). + // Read by serverless functions via the get_caller_claim host call. + // May be nil if the token has no custom claims. + Custom map[string]string `json:"custom,omitempty"` } // ParseAndVerifyJWT verifies a JWT created by this gateway using kid-based key diff --git a/core/pkg/gateway/config.go b/core/pkg/gateway/config.go index 41cdebb..323ae48 100644 --- a/core/pkg/gateway/config.go +++ b/core/pkg/gateway/config.go @@ -56,4 +56,15 @@ type Config struct { SFUPort int // Local SFU signaling port to proxy WebSocket connections to TURNDomain string // TURN server domain for credential generation TURNSecret string // HMAC-SHA1 shared secret for TURN credential generation + + // StealthCDNDomain, when set, makes the WebRTC credentials handler + // advertise turns::443 (served by the SNI router). + StealthCDNDomain string + + // Push notification configuration. Push is enabled when at least one + // provider URL/token is set. Tokens stored in the push_devices table + // are encrypted at rest via pkg/secrets using the cluster secret. + NtfyBaseURL string // ntfy server URL (e.g. "http://localhost:8080") + NtfyAuthToken string // optional bearer token for ntfy + ExpoAccessToken string // optional Expo access token } diff --git a/core/pkg/gateway/dependencies.go b/core/pkg/gateway/dependencies.go index eaad2dd..e1e8221 100644 --- a/core/pkg/gateway/dependencies.go +++ b/core/pkg/gateway/dependencies.go @@ -19,10 +19,15 @@ import ( "github.com/DeBrosOfficial/network/pkg/logging" "github.com/DeBrosOfficial/network/pkg/olric" "github.com/DeBrosOfficial/network/pkg/pubsub" + "github.com/DeBrosOfficial/network/pkg/push" + pushexpo "github.com/DeBrosOfficial/network/pkg/push/providers/expo" + pushntfy "github.com/DeBrosOfficial/network/pkg/push/providers/ntfy" "github.com/DeBrosOfficial/network/pkg/rqlite" "github.com/DeBrosOfficial/network/pkg/serverless" "github.com/DeBrosOfficial/network/pkg/serverless/hostfunctions" + "github.com/DeBrosOfficial/network/pkg/serverless/persistent" "github.com/DeBrosOfficial/network/pkg/serverless/triggers" + "github.com/DeBrosOfficial/network/pkg/serverless/wsbridge" "github.com/multiformats/go-multiaddr" olriclib "github.com/olric-data/olric" "go.uber.org/zap" @@ -63,6 +68,25 @@ type Dependencies struct { // PubSub trigger dispatcher (used to wire into PubSubHandlers) PubSubDispatcher *triggers.PubSubDispatcher + // Cron trigger store + scheduler. The scheduler is started by gateway + // lifecycle code after Dependencies is constructed; Stop is called + // during shutdown. + CronTriggerStore *triggers.CronTriggerStore + CronScheduler *triggers.CronScheduler + + // PersistentWSManager tracks long-lived WS function instances. + // Used by the WS handler when fn.WSPersistent=true; nil = disabled. + PersistentWSManager *persistent.Manager + + // WSBridge wires PubSub topics directly to WS clients on this gateway. + // Used by the ws_pubsub_bridge host function. Nil = disabled. + WSBridge *wsbridge.Bridge + + // Push notification dispatcher (nil when push isn't configured — + // hostfunc + HTTP handlers degrade to no-op / 503). + PushDispatcher *push.PushDispatcher + PushDeviceStore push.PushDeviceStore + // Authentication service AuthService *auth.Service } @@ -157,7 +181,17 @@ func initializeRQLite(logger *logging.ColoredLogger, cfg *Config, deps *Dependen db.SetConnMaxIdleTime(2 * time.Minute) // Maximum idle time before closing deps.SQLDB = db - orm := rqlite.NewClient(db) + // Use the DSN-aware constructor so the ORM client also has a native + // *gorqlite.Connection for atomic Batch operations. If the native dial + // fails, fall back to the stdlib-only client (Batch will be unavailable + // but everything else works). + orm, ormErr := rqlite.NewClientWithDSN(db, dsn) + if ormErr != nil { + logger.ComponentWarn(logging.ComponentGeneral, + "native gorqlite dial failed, atomic Batch will be unavailable", + zap.Error(ormErr)) + orm = rqlite.NewClient(db) + } deps.ORMClient = orm deps.ORMHTTP = rqlite.NewHTTPGateway(orm, "/v1/db") // Set a reasonable timeout for HTTP requests (30 seconds) @@ -412,11 +446,29 @@ func initializeServerless(logger *logging.ColoredLogger, cfg *Config, deps *Depe secretsMgr = smImpl } + // Initialize push notification dispatcher if any provider is configured. + // Devices are stored encrypted in RQLite (see migration 023). Providers + // are registered based on gateway config; missing config = provider absent. + pushDispatcher, pushStore, err := buildPushDispatcher(cfg, deps.ORMClient, logger) + if err != nil { + // Non-fatal: log and continue. Functions calling push_send will get nil + // (silent no-op) and HTTP /v1/push/* endpoints return 503. + logger.ComponentWarn(logging.ComponentGeneral, + "push notifications disabled (init failed)", zap.Error(err)) + } + deps.PushDispatcher = pushDispatcher + deps.PushDeviceStore = pushStore + // Create host functions provider (allows functions to call Orama services) hostFuncsCfg := hostfunctions.HostFunctionsConfig{ IPFSAPIURL: cfg.IPFSAPIURL, HTTPTimeout: 30 * time.Second, } + // WS-PubSub bridge: wire PubSub topics directly to WS clients without + // per-event WASM invocation. The bridge is a thin layer over the + // pubsub adapter + WSManager. + deps.WSBridge = wsbridge.New(pubsubAdapter, deps.ServerlessWSMgr, logger.Logger) + hostFuncs := hostfunctions.NewHostFunctions( deps.ORMClient, olricClient, @@ -424,12 +476,20 @@ func initializeServerless(logger *logging.ColoredLogger, cfg *Config, deps *Depe pubsubAdapter, // pubsub adapter for serverless functions deps.ServerlessWSMgr, secretsMgr, + pushDispatcher, // may be nil — PushSend hostfunc handles that + deps.WSBridge, // may be nil; WSPubSubBridge returns explicit error hostFuncsCfg, logger.Logger, ) - // Create WASM engine with rate limiter - rateLimiter := serverless.NewTokenBucketLimiter(engineCfg.GlobalRateLimitPerMinute) + // Create WASM engine with multi-tier rate limiter (per-(ns, fn, wallet, ip), + // per-(ns, wallet), per-(ns)). The legacy global limit is honored as + // the per-namespace ceiling so no behavior regresses for existing deployments. + rlCfg := serverless.DefaultLimiterConfig() + if engineCfg.GlobalRateLimitPerMinute > 0 { + rlCfg.PerNamespacePerMinute = engineCfg.GlobalRateLimitPerMinute + } + rateLimiter := serverless.NewMultiTierLimiter(rlCfg) engine, err := serverless.NewEngine(engineCfg, registry, hostFuncs, logger.Logger, serverless.WithInvocationLogger(registry), serverless.WithRateLimiter(rateLimiter), @@ -442,6 +502,11 @@ func initializeServerless(logger *logging.ColoredLogger, cfg *Config, deps *Depe // Create invoker deps.ServerlessInvoker = serverless.NewInvoker(engine, registry, hostFuncs, logger.Logger) + // Wire the invoker back into hostFuncs so the function_invoke host + // function can dispatch sub-invocations from inside a WASM function + // (e.g. rpc-router routing client RPCs to per-op handlers). + hostFuncs.SetInvoker(deps.ServerlessInvoker) + // Create PubSub trigger store and dispatcher triggerStore := triggers.NewPubSubTriggerStore(deps.ORMClient, logger.Logger) @@ -456,13 +521,34 @@ func initializeServerless(logger *logging.ColoredLogger, cfg *Config, deps *Depe logger.Logger, ) + // Cron trigger store + scheduler. The scheduler polls + // function_cron_triggers and invokes due rows via the same + // ServerlessInvoker used for PubSub triggers; the ↓ Start call wires + // the goroutine up — Stop is invoked from gateway lifecycle shutdown. + cronStore := triggers.NewCronTriggerStore(deps.ORMClient, logger.Logger) + deps.CronTriggerStore = cronStore + deps.CronScheduler = triggers.NewCronScheduler( + cronStore, + deps.ServerlessInvoker, + logger.Logger, + 30*time.Second, + ) + + // Persistent WS instance manager. Cap from gateway config (TODO: surface + // the knob); 5000 is a sensible default per plan 06. + deps.PersistentWSManager = persistent.NewManager(5000, logger.Logger) + // Create HTTP handlers deps.ServerlessHandlers = serverlesshandlers.NewServerlessHandlers( deps.ServerlessInvoker, + deps.ServerlessEngine, registry, deps.ServerlessWSMgr, triggerStore, + cronStore, deps.PubSubDispatcher, + deps.PersistentWSManager, + deps.WSBridge, secretsMgr, logger.Logger, ) @@ -686,3 +772,40 @@ func injectRQLiteAuth(dsn, username, password string) string { } return dsn } + +// buildPushDispatcher constructs a push.PushDispatcher + device store with +// all enabled providers. Returns (nil, nil, nil) when no provider is +// configured — that's a supported state, not an error. Returns (nil, nil, +// err) on hard init failures (e.g. cluster secret missing for the +// encrypted device store). +func buildPushDispatcher(cfg *Config, db rqlite.Client, logger *logging.ColoredLogger) (*push.PushDispatcher, push.PushDeviceStore, error) { + if cfg.NtfyBaseURL == "" && cfg.ExpoAccessToken == "" { + // No providers configured — push is disabled. + return nil, nil, nil + } + if cfg.ClusterSecret == "" { + // Devices are encrypted at rest using a cluster-secret-derived key. + // Without it we can't store anything safely. + return nil, nil, fmt.Errorf("push enabled but ClusterSecret is empty") + } + store, err := push.NewRqliteDeviceStore(db, cfg.ClusterSecret, logger.Logger) + if err != nil { + return nil, nil, fmt.Errorf("init push device store: %w", err) + } + d := push.New(store, logger.Logger) + if cfg.NtfyBaseURL != "" { + d.Register(pushntfy.New(pushntfy.Config{ + BaseURL: cfg.NtfyBaseURL, + AuthToken: cfg.NtfyAuthToken, + }, logger.Logger)) + logger.ComponentInfo(logging.ComponentGeneral, "push provider registered: ntfy", + zap.String("base_url", cfg.NtfyBaseURL)) + } + if cfg.ExpoAccessToken != "" { + d.Register(pushexpo.New(pushexpo.Config{ + AccessToken: cfg.ExpoAccessToken, + }, logger.Logger)) + logger.ComponentInfo(logging.ComponentGeneral, "push provider registered: expo") + } + return d, store, nil +} diff --git a/core/pkg/gateway/gateway.go b/core/pkg/gateway/gateway.go index 389ab00..50995db 100644 --- a/core/pkg/gateway/gateway.go +++ b/core/pkg/gateway/gateway.go @@ -28,10 +28,12 @@ import ( "github.com/DeBrosOfficial/network/pkg/gateway/handlers/cache" deploymentshandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/deployments" pubsubhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/pubsub" + pushhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/push" serverlesshandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/serverless" enrollhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/enroll" joinhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/join" webrtchandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/webrtc" + operatorhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/operator" vaulthandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/vault" wireguardhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/wireguard" sqlitehandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/sqlite" @@ -42,6 +44,8 @@ import ( "github.com/DeBrosOfficial/network/pkg/olric" "github.com/DeBrosOfficial/network/pkg/rqlite" "github.com/DeBrosOfficial/network/pkg/serverless" + "github.com/DeBrosOfficial/network/pkg/serverless/persistent" + "github.com/DeBrosOfficial/network/pkg/serverless/triggers" _ "github.com/mattn/go-sqlite3" "go.uber.org/zap" ) @@ -82,13 +86,17 @@ type Gateway struct { mu sync.RWMutex presenceMu sync.RWMutex pubsubHandlers *pubsubhandlers.PubSubHandlers + pushHandlers *pushhandlers.Handlers // Serverless function engine - serverlessEngine *serverless.Engine - serverlessRegistry *serverless.Registry - serverlessInvoker *serverless.Invoker - serverlessWSMgr *serverless.WSManager - serverlessHandlers *serverlesshandlers.ServerlessHandlers + serverlessEngine *serverless.Engine + serverlessRegistry *serverless.Registry + serverlessInvoker *serverless.Invoker + serverlessWSMgr *serverless.WSManager + serverlessHandlers *serverlesshandlers.ServerlessHandlers + pubsubDispatcher *triggers.PubSubDispatcher + persistentWSManager *persistent.Manager + cronScheduler *triggers.CronScheduler // Authentication service authService *auth.Service @@ -168,7 +176,8 @@ type Gateway struct { proxyTransport *http.Transport // Vault proxy handlers - vaultHandlers *vaulthandlers.Handlers + vaultHandlers *vaulthandlers.Handlers + operatorHandler *operatorhandlers.Handler // Namespace health state (local service probes + hourly reconciliation) nsHealth *namespaceHealthState @@ -340,10 +349,27 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) { // Wire PubSub trigger dispatch if serverless is available if deps.PubSubDispatcher != nil { + gw.pubsubDispatcher = deps.PubSubDispatcher gw.pubsubHandlers.SetOnPublish(func(ctx context.Context, namespace, topic string, data []byte) { deps.PubSubDispatcher.Dispatch(ctx, namespace, topic, data, 0) }) } + if deps.PersistentWSManager != nil { + gw.persistentWSManager = deps.PersistentWSManager + } + if deps.CronScheduler != nil { + gw.cronScheduler = deps.CronScheduler + // Background goroutine — Stop is called from gateway.Close. + gw.cronScheduler.Start(context.Background()) + } + + // Push notification handlers — disabled when no provider is configured. + // The handlers themselves return 503 if dispatcher/store is nil; we + // register them unconditionally so the routes always exist with a + // predictable shape. + if deps.PushDispatcher != nil { + gw.pushHandlers = pushhandlers.NewHandlers(deps.PushDispatcher, deps.PushDeviceStore, logger) + } if cfg.WebRTCEnabled && cfg.SFUPort > 0 { gw.webrtcHandlers = webrtchandlers.NewWebRTCHandlers( @@ -405,6 +431,7 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) { gw.joinHandler = joinhandlers.NewHandler(logger.Logger, deps.ORMClient, cfg.DataDir) gw.enrollHandler = enrollhandlers.NewHandler(logger.Logger, deps.ORMClient, cfg.DataDir) gw.vaultHandlers = vaulthandlers.NewHandlers(logger, deps.Client) + gw.operatorHandler = operatorhandlers.NewHandler(logger.Logger, deps.ORMClient) } // Initialize deployment system diff --git a/core/pkg/gateway/handlers/deployments/mocks_test.go b/core/pkg/gateway/handlers/deployments/mocks_test.go index 491048d..eb81040 100644 --- a/core/pkg/gateway/handlers/deployments/mocks_test.go +++ b/core/pkg/gateway/handlers/deployments/mocks_test.go @@ -162,6 +162,15 @@ func (m *mockRQLiteClient) Tx(ctx context.Context, fn func(tx rqlite.Tx) error) return nil } +func (m *mockRQLiteClient) Batch(ctx context.Context, ops []rqlite.BatchOp) (*rqlite.BatchResult, error) { + return &rqlite.BatchResult{Committed: true, Results: make([]rqlite.OpResult, len(ops))}, nil +} + +func (m *mockRQLiteClient) BatchWithSeq(ctx context.Context, namespace string, ops []rqlite.BatchOp) (*rqlite.BatchResult, int64, error) { + res, err := m.Batch(ctx, ops) + return res, 1, err +} + // mockProcessManager implements a mock process manager for testing type mockProcessManager struct { StartFunc func(ctx context.Context, deployment *deployments.Deployment, workDir string) error diff --git a/core/pkg/gateway/handlers/join/handler.go b/core/pkg/gateway/handlers/join/handler.go index 678c82f..dd79485 100644 --- a/core/pkg/gateway/handlers/join/handler.go +++ b/core/pkg/gateway/handlers/join/handler.go @@ -129,6 +129,9 @@ func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) { return } + // 1b. Look up the operator wallet from the consumed token (may be empty for legacy tokens) + operatorWallet := h.tokenOperatorWallet(ctx, req.Token) + // 2. Clean up stale WG entries for this public IP (from previous installs). // This prevents ghost peers: old rows with different node_id/wg_key that // the sync loop would keep trying to reach. @@ -150,8 +153,8 @@ func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) { // 4. Register WG peer in database nodeID := fmt.Sprintf("node-%s", wgIP) // temporary ID based on WG IP _, err = h.rqliteClient.Exec(ctx, - "INSERT OR REPLACE INTO wireguard_peers (node_id, wg_ip, public_key, public_ip, wg_port) VALUES (?, ?, ?, ?, ?)", - nodeID, wgIP, req.WGPublicKey, req.PublicIP, 51820) + "INSERT OR REPLACE INTO wireguard_peers (node_id, wg_ip, public_key, public_ip, wg_port, operator_wallet) VALUES (?, ?, ?, ?, ?, ?)", + nodeID, wgIP, req.WGPublicKey, req.PublicIP, 51820, operatorWallet) if err != nil { h.logger.Error("failed to register WG peer", zap.Error(err)) http.Error(w, "failed to register peer", http.StatusInternalServerError) @@ -307,6 +310,22 @@ func (h *Handler) consumeToken(ctx context.Context, token, usedByIP string) erro return nil } +// tokenOperatorWallet looks up the operator_wallet from a consumed invite token. +// Returns empty string if the token has no operator (legacy tokens). +func (h *Handler) tokenOperatorWallet(ctx context.Context, token string) string { + var rows []struct { + Wallet string `db:"operator_wallet"` + } + if err := h.rqliteClient.Query(ctx, &rows, + "SELECT COALESCE(operator_wallet, '') AS operator_wallet FROM invite_tokens WHERE token = ?", token); err != nil { + return "" + } + if len(rows) > 0 { + return rows[0].Wallet + } + return "" +} + // assignWGIP finds the next available 10.0.0.x IP by querying all peers and // finding the numerically highest IP. This avoids lexicographic comparison issues // where MAX("10.0.0.9") > MAX("10.0.0.10") in SQL string comparison. diff --git a/core/pkg/gateway/handlers/operator/handler.go b/core/pkg/gateway/handlers/operator/handler.go new file mode 100644 index 0000000..d11d2e1 --- /dev/null +++ b/core/pkg/gateway/handlers/operator/handler.go @@ -0,0 +1,99 @@ +// Package operator provides HTTP handlers for node operator management. +// +// Operators authenticate via wallet JWT (same auth flow as namespaces). +// Each operator's nodes are tracked by their wallet address in the +// dns_nodes and wireguard_peers tables. +package operator + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/DeBrosOfficial/network/pkg/gateway/auth" + "github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys" + "github.com/DeBrosOfficial/network/pkg/rqlite" + "go.uber.org/zap" +) + +// Handler provides HTTP handlers for operator node management. +type Handler struct { + logger *zap.Logger + rqliteClient rqlite.Client +} + +// NewHandler creates an operator handler. +func NewHandler(logger *zap.Logger, rqliteClient rqlite.Client) *Handler { + return &Handler{ + logger: logger, + rqliteClient: rqliteClient, + } +} + +// walletFromRequest extracts the operator's wallet address from the request. +// Supports both JWT auth (wallet in Sub claim) and API key auth (wallet looked +// up from wallet_api_keys table). +func (h *Handler) walletFromRequest(r *http.Request) string { + // 1. Try JWT claims first (wallet JWT auth sets Sub = "0x...") + if claims, ok := r.Context().Value(ctxkeys.JWT).(*auth.JWTClaims); ok && claims != nil { + sub := strings.TrimSpace(claims.Sub) + if strings.HasPrefix(strings.ToLower(sub), "0x") { + return sub + } + // JWT with API key subject + if strings.HasPrefix(strings.ToLower(sub), "ak_") { + return h.resolveWalletFromAPIKey(r.Context(), sub) + } + } + + // 2. Try API key from context (X-API-Key header, no JWT) + if apiKey, ok := r.Context().Value(ctxkeys.APIKey).(string); ok && apiKey != "" { + return h.resolveWalletFromAPIKey(r.Context(), apiKey) + } + + return "" +} + +// resolveWalletFromAPIKey looks up the wallet address linked to an API key. +// It queries namespace_ownership for a wallet-type owner of the namespace. +func (h *Handler) resolveWalletFromAPIKey(ctx context.Context, apiKeySub string) string { + if h.rqliteClient == nil { + return "" + } + ns := extractNamespace(apiKeySub) + if ns == "" { + return "" + } + var rows []struct { + OwnerID string `db:"owner_id"` + } + if err := h.rqliteClient.Query(ctx, &rows, + `SELECT no.owner_id FROM namespace_ownership no + JOIN namespaces n ON no.namespace_id = n.id + WHERE n.name = ? AND no.owner_type = 'wallet' + LIMIT 1`, + ns); err != nil || len(rows) == 0 { + return "" + } + return rows[0].OwnerID +} + +// extractNamespace extracts the namespace from an API key subject like "ak_xxx:namespace". +func extractNamespace(apiKeySub string) string { + parts := strings.SplitN(apiKeySub, ":", 2) + if len(parts) == 2 { + return parts[1] + } + return apiKeySub +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/core/pkg/gateway/handlers/operator/handler_test.go b/core/pkg/gateway/handlers/operator/handler_test.go new file mode 100644 index 0000000..b136c9a --- /dev/null +++ b/core/pkg/gateway/handlers/operator/handler_test.go @@ -0,0 +1,242 @@ +package operator + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/DeBrosOfficial/network/pkg/gateway/auth" + "github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys" +) + +func TestWalletFromRequest_withClaims(t *testing.T) { + h := NewHandler(nil, nil) + r := httptest.NewRequest(http.MethodGet, "/", nil) + claims := &auth.JWTClaims{Sub: "0xabc123"} + ctx := context.WithValue(r.Context(), ctxkeys.JWT, claims) + r = r.WithContext(ctx) + + wallet := h.walletFromRequest(r) + if wallet != "0xabc123" { + t.Errorf("wallet = %q, want %q", wallet, "0xabc123") + } +} + +func TestWalletFromRequest_noClaims(t *testing.T) { + h := NewHandler(nil, nil) + r := httptest.NewRequest(http.MethodGet, "/", nil) + + wallet := h.walletFromRequest(r) + if wallet != "" { + t.Errorf("wallet = %q, want empty", wallet) + } +} + +func TestWalletFromRequest_nilClaims(t *testing.T) { + h := NewHandler(nil, nil) + r := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := context.WithValue(r.Context(), ctxkeys.JWT, (*auth.JWTClaims)(nil)) + r = r.WithContext(ctx) + + wallet := h.walletFromRequest(r) + if wallet != "" { + t.Errorf("wallet = %q, want empty", wallet) + } +} + +func TestWalletFromRequest_apiKeyContext(t *testing.T) { + // When auth middleware sets ctxkeys.APIKey (no JWT), walletFromRequest + // should try to resolve via the API key. With nil rqliteClient it returns + // empty (can't query DB), but it shouldn't panic. + h := NewHandler(nil, nil) + r := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := context.WithValue(r.Context(), ctxkeys.APIKey, "ak_test:myns") + r = r.WithContext(ctx) + + // Should not panic — returns empty because no DB to query + wallet := h.walletFromRequest(r) + if wallet != "" { + t.Errorf("wallet = %q, want empty (no DB)", wallet) + } +} + +func TestExtractNamespace(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"ak_abc123:myns", "myns"}, + {"ak_abc123", "ak_abc123"}, + {"", ""}, + } + for _, tt := range tests { + got := extractNamespace(tt.input) + if got != tt.want { + t.Errorf("extractNamespace(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestDecodeJSON_valid(t *testing.T) { + body := strings.NewReader(`{"node_id":"test-node","environment":"devnet"}`) + r := httptest.NewRequest(http.MethodPost, "/", body) + + var req RegisterRequest + if err := decodeJSON(r, &req); err != nil { + t.Fatalf("decodeJSON: %v", err) + } + if req.NodeID != "test-node" { + t.Errorf("NodeID = %q, want %q", req.NodeID, "test-node") + } + if req.Environment != "devnet" { + t.Errorf("Environment = %q, want %q", req.Environment, "devnet") + } +} + +func TestDecodeJSON_invalid(t *testing.T) { + body := strings.NewReader(`not-json`) + r := httptest.NewRequest(http.MethodPost, "/", body) + + var req RegisterRequest + if err := decodeJSON(r, &req); err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestHandleInvite_noAuth(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/v1/operator/invite", nil) + + h.HandleInvite(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestHandleInvite_wrongMethod(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/v1/operator/invite", nil) + + h.HandleInvite(w, r) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed) + } +} + +func TestHandleListNodes_noAuth(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/v1/operator/nodes", nil) + + h.HandleListNodes(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestHandleListNodes_wrongMethod(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/v1/operator/nodes", nil) + + h.HandleListNodes(w, r) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed) + } +} + +func TestHandleRegister_noAuth(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register", strings.NewReader(`{"node_id":"test"}`)) + + h.HandleRegister(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} + +func TestHandleRegister_missingFields(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register", strings.NewReader(`{}`)) + claims := &auth.JWTClaims{Sub: "0xabc"} + r = r.WithContext(context.WithValue(r.Context(), ctxkeys.JWT, claims)) + + h.HandleRegister(w, r) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest) + } +} + +func TestHandleRegister_invalidEnvironment(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register", + strings.NewReader(`{"node_id":"test","environment":""}`)) + claims := &auth.JWTClaims{Sub: "0xabc"} + r = r.WithContext(context.WithValue(r.Context(), ctxkeys.JWT, claims)) + + h.HandleRegister(w, r) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest) + } +} + +func TestHandleRegister_invalidRole(t *testing.T) { + h := NewHandler(nil, nil) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register", + strings.NewReader(`{"node_id":"test","role":"admin"}`)) + claims := &auth.JWTClaims{Sub: "0xabc"} + r = r.WithContext(context.WithValue(r.Context(), ctxkeys.JWT, claims)) + + h.HandleRegister(w, r) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest) + } +} + +func TestAllowedEnvironments(t *testing.T) { + valid := []string{"devnet", "testnet", "sandbox", "production", "mainnet"} + invalid := []string{"staging", "local", "