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", "