Merge pull request #89 from DeBrosDAO/blockchain

Blockchain
This commit is contained in:
anonpenguin 2026-05-05 12:30:45 +03:00 committed by GitHub
commit 46d511eb5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
204 changed files with 16290 additions and 3888 deletions

4
.gitignore vendored
View File

@ -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/

View File

@ -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)

View File

@ -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
}

242
core/cmd/sni-router/main.go Normal file
View File

@ -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
}

187
core/docs/STEALTH_TURN.md Normal file
View File

@ -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.<base> *.<base>, <base>
turn.<base> (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.<base-domain>`
Caddy's automatic Let's Encrypt flow needs to issue a cert covering
`cdn.<base-domain>` and `cdn.ns-*.<base-domain>` 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.<base>:443 # should hit TURN backend (TLS handshake will fail; that's fine)
curl -v https://<base>: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.<base-domain>")
```
The credentials handler will start including `turns:cdn.<base-domain>: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.<base-domain>`, 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.

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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
);

View File

@ -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;

View File

@ -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

View File

@ -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)
}

View File

@ -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)")
}

View File

@ -32,4 +32,6 @@ func init() {
Cmd.AddCommand(recoverRaftCmd)
Cmd.AddCommand(enrollCmd)
Cmd.AddCommand(unlockCmd)
Cmd.AddCommand(migrateConfCmd)
Cmd.AddCommand(setupCmd)
}

View File

@ -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")
}

View File

@ -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 <ip> --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)")
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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 <ip-or-hostname> [-- 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()
}

View File

@ -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")
}

View File

@ -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)
}

View File

@ -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()

View File

@ -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")
}
}

View File

@ -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))
}

View File

@ -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 <trigger-id>`,
}
// TriggersAddCmd adds a PubSub trigger to a function.
// TriggersAddCmd adds a PubSub or Cron trigger to a function.
var TriggersAddCmd = &cobra.Command{
Use: "add <function-name>",
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
}

View File

@ -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

View File

@ -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))
}
}

View File

@ -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
}

View File

@ -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(`<html>not json</html>`))
}))
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")
}
}

View File

@ -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

View File

@ -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

View File

@ -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{

View File

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

View File

@ -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.

View File

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

View File

@ -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()
})

View File

@ -13,6 +13,7 @@ var coreServices = []string{
"orama-olric",
"orama-ipfs",
"orama-ipfs-cluster",
"orama-vault",
"orama-anyone-relay",
"orama-anyone-client",
"coredns",

View File

@ -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 {

View File

@ -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
}

View File

@ -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 <IP> --password '<PASS>' --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
}

View File

@ -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)",
}

View File

@ -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

View File

@ -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 {

View File

@ -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 {

View File

@ -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 != "" {

View File

@ -162,6 +162,7 @@ func GetProductionServices() []string {
"orama-olric",
"orama-ipfs-cluster",
"orama-ipfs",
"orama-vault",
"orama-anyone-client",
"orama-anyone-relay",
}

View File

@ -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)

View File

@ -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)

View File

@ -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
}

View File

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

View File

@ -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()

View File

@ -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)
}

View File

@ -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()

View File

@ -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 {

View File

@ -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

View File

@ -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.

View File

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

View File

@ -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

View File

@ -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

View File

@ -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:<StealthCDNDomain>: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
}

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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})
}

View File

@ -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":"<script>alert(1)</script>"}`))
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", "<script>", ""}
for _, env := range valid {
if !allowedEnvironments[env] {
t.Errorf("expected %q to be allowed", env)
}
}
for _, env := range invalid {
if allowedEnvironments[env] {
t.Errorf("expected %q to be disallowed", env)
}
}
}
func TestAllowedRoles(t *testing.T) {
valid := []string{"node", "nameserver", "nameserver-ns1", "nameserver-ns2", "nameserver-ns3"}
invalid := []string{"admin", "root", ""}
for _, role := range valid {
if !allowedRoles[role] {
t.Errorf("expected %q to be allowed", role)
}
}
for _, role := range invalid {
if allowedRoles[role] {
t.Errorf("expected %q to be disallowed", role)
}
}
}

View File

@ -0,0 +1,79 @@
package operator
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"time"
"go.uber.org/zap"
)
// InviteRequest is the optional body for POST /v1/operator/invite.
type InviteRequest struct {
ExpiryMinutes int `json:"expiry_minutes,omitempty"` // Default: 60
}
// InviteResponse is returned on success.
type InviteResponse struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
// HandleInvite generates an invite token tagged with the operator's wallet.
// Requires wallet JWT authentication.
//
// POST /v1/operator/invite
func (h *Handler) HandleInvite(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
wallet := h.walletFromRequest(r)
if wallet == "" {
writeError(w, http.StatusUnauthorized, "wallet authentication required")
return
}
// Parse optional expiry from body (default: 60min, max: 7 days).
expiryMinutes := 60
if r.Body != nil && r.ContentLength > 0 {
var req InviteRequest
if err := decodeJSON(r, &req); err == nil && req.ExpiryMinutes > 0 {
expiryMinutes = req.ExpiryMinutes
}
}
const maxExpiryMinutes = 10080 // 7 days
if expiryMinutes > maxExpiryMinutes {
expiryMinutes = maxExpiryMinutes
}
// Generate random 32-byte token.
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
h.logger.Error("failed to generate invite token", zap.Error(err))
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
token := hex.EncodeToString(tokenBytes)
expiresAt := time.Now().UTC().Add(time.Duration(expiryMinutes) * time.Minute)
expiresAtStr := expiresAt.Format("2006-01-02 15:04:05")
ctx := r.Context()
_, err := h.rqliteClient.Exec(ctx,
"INSERT INTO invite_tokens (token, created_by, expires_at, operator_wallet) VALUES (?, ?, ?, ?)",
token, fmt.Sprintf("operator:%s", wallet), expiresAtStr, wallet)
if err != nil {
h.logger.Error("failed to store invite token", zap.Error(err))
writeError(w, http.StatusInternalServerError, "failed to create invite token")
return
}
writeJSON(w, http.StatusOK, InviteResponse{
Token: token,
ExpiresAt: expiresAtStr,
})
}

View File

@ -0,0 +1,74 @@
package operator
import (
"net/http"
"go.uber.org/zap"
)
// NodeInfo represents a node owned by the operator.
type NodeInfo struct {
ID string `json:"id" db:"id"`
IPAddress string `json:"ip_address" db:"ip_address"`
InternalIP string `json:"internal_ip,omitempty" db:"internal_ip"`
Environment string `json:"environment,omitempty" db:"environment"`
Role string `json:"role,omitempty" db:"role"`
SSHUser string `json:"ssh_user,omitempty" db:"ssh_user"`
Status string `json:"status" db:"status"`
Region string `json:"region,omitempty" db:"region"`
LastSeen string `json:"last_seen,omitempty" db:"last_seen"`
OperatorWallet string `json:"operator_wallet,omitempty" db:"operator_wallet"`
}
// ListNodesResponse is returned by GET /v1/operator/nodes.
type ListNodesResponse struct {
Nodes []NodeInfo `json:"nodes"`
}
// HandleListNodes returns all nodes owned by the authenticated operator.
// Optionally filtered by ?env=<environment>.
//
// GET /v1/operator/nodes
func (h *Handler) HandleListNodes(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
wallet := h.walletFromRequest(r)
if wallet == "" {
writeError(w, http.StatusUnauthorized, "wallet authentication required")
return
}
ctx := r.Context()
envFilter := r.URL.Query().Get("env")
query := `SELECT id, ip_address, COALESCE(internal_ip, '') AS internal_ip,
COALESCE(environment, 'production') AS environment,
COALESCE(role, 'node') AS role, COALESCE(ssh_user, 'root') AS ssh_user,
status, COALESCE(region, '') AS region, COALESCE(last_seen, '') AS last_seen,
COALESCE(operator_wallet, '') AS operator_wallet
FROM dns_nodes WHERE operator_wallet = ?`
args := []interface{}{wallet}
if envFilter != "" {
query += " AND environment = ?"
args = append(args, envFilter)
}
query += " ORDER BY environment, ip_address"
var nodes []NodeInfo
if err := h.rqliteClient.Query(ctx, &nodes, query, args...); err != nil {
h.logger.Error("failed to query operator nodes", zap.Error(err))
writeError(w, http.StatusInternalServerError, "failed to query nodes")
return
}
if nodes == nil {
nodes = []NodeInfo{}
}
writeJSON(w, http.StatusOK, ListNodesResponse{Nodes: nodes})
}

View File

@ -0,0 +1,138 @@
package operator
import (
"encoding/json"
"io"
"net/http"
"go.uber.org/zap"
)
// RegisterRequest is the body for POST /v1/operator/node/register.
type RegisterRequest struct {
NodeID string `json:"node_id"` // dns_nodes.id (peer ID or hostname)
IPAddress string `json:"ip_address,omitempty"` // Public IP (alternative lookup key)
Environment string `json:"environment,omitempty"` // e.g., "devnet", "sandbox"
Role string `json:"role,omitempty"` // e.g., "node", "nameserver"
SSHUser string `json:"ssh_user,omitempty"` // SSH user (default: "root")
}
var (
allowedEnvironments = map[string]bool{
"production": true, "devnet": true, "testnet": true, "sandbox": true, "mainnet": true,
}
allowedRoles = map[string]bool{
"node": true, "nameserver": true, "nameserver-ns1": true, "nameserver-ns2": true, "nameserver-ns3": true,
}
)
// HandleRegister tags an existing node with the operator's wallet.
// The node must already exist in dns_nodes and be either unclaimed or
// already owned by the requesting operator.
//
// POST /v1/operator/node/register
func (h *Handler) HandleRegister(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
wallet := h.walletFromRequest(r)
if wallet == "" {
writeError(w, http.StatusUnauthorized, "wallet authentication required")
return
}
var req RegisterRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.NodeID == "" && req.IPAddress == "" {
writeError(w, http.StatusBadRequest, "node_id or ip_address required")
return
}
if req.Environment != "" && !allowedEnvironments[req.Environment] {
writeError(w, http.StatusBadRequest, "invalid environment")
return
}
if req.Role != "" && !allowedRoles[req.Role] {
writeError(w, http.StatusBadRequest, "invalid role")
return
}
ctx := r.Context()
// Build the UPDATE dynamically based on what fields are provided.
setClauses := "operator_wallet = ?"
args := []interface{}{wallet}
if req.Environment != "" {
setClauses += ", environment = ?"
args = append(args, req.Environment)
}
if req.Role != "" {
setClauses += ", role = ?"
args = append(args, req.Role)
}
if req.SSHUser != "" {
setClauses += ", ssh_user = ?"
args = append(args, req.SSHUser)
}
setClauses += ", updated_at = datetime('now')"
// Match by node_id or ip_address. Only allow claiming unclaimed nodes
// or nodes already owned by this operator (prevents hijacking).
var whereClause string
if req.NodeID != "" {
whereClause = "id = ? AND (operator_wallet IS NULL OR operator_wallet = '' OR operator_wallet = ?)"
args = append(args, req.NodeID, wallet)
} else {
whereClause = "ip_address = ? AND (operator_wallet IS NULL OR operator_wallet = '' OR operator_wallet = ?)"
args = append(args, req.IPAddress, wallet)
}
query := "UPDATE dns_nodes SET " + setClauses + " WHERE " + whereClause
result, err := h.rqliteClient.Exec(ctx, query, args...)
if err != nil {
h.logger.Error("failed to register node with operator", zap.Error(err))
writeError(w, http.StatusInternalServerError, "failed to register node")
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
h.logger.Error("failed to check rows affected", zap.Error(err))
writeError(w, http.StatusInternalServerError, "failed to register node")
return
}
if rowsAffected == 0 {
writeError(w, http.StatusNotFound, "node not found or owned by another operator")
return
}
// Also update wireguard_peers if we can match by public_ip.
if req.IPAddress != "" {
if _, err := h.rqliteClient.Exec(ctx,
"UPDATE wireguard_peers SET operator_wallet = ? WHERE public_ip = ?",
wallet, req.IPAddress); err != nil {
h.logger.Warn("failed to update operator_wallet on wireguard_peers", zap.Error(err))
}
}
writeJSON(w, http.StatusOK, map[string]string{
"status": "registered",
"wallet": wallet,
"node_id": req.NodeID,
})
}
func decodeJSON(r *http.Request, v interface{}) error {
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
if err != nil {
return err
}
return json.Unmarshal(body, v)
}

View File

@ -21,10 +21,12 @@ import (
// mockPubSubClient implements client.PubSubClient for testing
type mockPubSubClient struct {
PublishFunc func(ctx context.Context, topic string, data []byte) error
SubscribeFunc func(ctx context.Context, topic string, handler client.MessageHandler) error
UnsubscribeFunc func(ctx context.Context, topic string) error
ListTopicsFunc func(ctx context.Context) ([]string, error)
PublishFunc func(ctx context.Context, topic string, data []byte) error
PublishBatchFunc func(ctx context.Context, msgs []client.TopicMessage, opts client.PublishBatchOptions) error
PublishSameFunc func(ctx context.Context, topics []string, data []byte, opts client.PublishBatchOptions) error
SubscribeFunc func(ctx context.Context, topic string, handler client.MessageHandler) error
UnsubscribeFunc func(ctx context.Context, topic string) error
ListTopicsFunc func(ctx context.Context) ([]string, error)
}
func (m *mockPubSubClient) Publish(ctx context.Context, topic string, data []byte) error {
@ -34,6 +36,20 @@ func (m *mockPubSubClient) Publish(ctx context.Context, topic string, data []byt
return nil
}
func (m *mockPubSubClient) PublishBatch(ctx context.Context, msgs []client.TopicMessage, opts client.PublishBatchOptions) error {
if m.PublishBatchFunc != nil {
return m.PublishBatchFunc(ctx, msgs, opts)
}
return nil
}
func (m *mockPubSubClient) PublishSame(ctx context.Context, topics []string, data []byte, opts client.PublishBatchOptions) error {
if m.PublishSameFunc != nil {
return m.PublishSameFunc(ctx, topics, data, opts)
}
return nil
}
func (m *mockPubSubClient) Subscribe(ctx context.Context, topic string, handler client.MessageHandler) error {
if m.SubscribeFunc != nil {
return m.SubscribeFunc(ctx, topic, handler)

View File

@ -0,0 +1,156 @@
package pubsub
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/DeBrosOfficial/network/pkg/client"
)
func TestPublishBatchHandler_invalid_method(t *testing.T) {
h := newTestHandlers(&mockNetworkClient{pubsub: &mockPubSubClient{}})
req := withNamespace(httptest.NewRequest(http.MethodGet, "/v1/pubsub/publish-batch", nil), "ns")
rr := httptest.NewRecorder()
h.PublishBatchHandler(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", rr.Code)
}
}
func TestPublishBatchHandler_missing_namespace(t *testing.T) {
h := newTestHandlers(&mockNetworkClient{pubsub: &mockPubSubClient{}})
body, _ := json.Marshal(PublishBatchRequest{Messages: []PublishBatchEntry{{Topic: "a", DataB64: "AA=="}}})
req := httptest.NewRequest(http.MethodPost, "/v1/pubsub/publish-batch", bytes.NewReader(body))
rr := httptest.NewRecorder()
h.PublishBatchHandler(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d (body: %s)", rr.Code, rr.Body.String())
}
}
func TestPublishBatchHandler_empty_messages_rejected(t *testing.T) {
h := newTestHandlers(&mockNetworkClient{pubsub: &mockPubSubClient{}})
body, _ := json.Marshal(PublishBatchRequest{Messages: []PublishBatchEntry{}})
req := withNamespace(httptest.NewRequest(http.MethodPost, "/v1/pubsub/publish-batch", bytes.NewReader(body)), "ns")
rr := httptest.NewRecorder()
h.PublishBatchHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for empty messages, got %d", rr.Code)
}
}
func TestPublishBatchHandler_oversize_batch_rejected(t *testing.T) {
h := newTestHandlers(&mockNetworkClient{pubsub: &mockPubSubClient{}})
entries := make([]PublishBatchEntry, MaxPublishBatchSize+1)
for i := range entries {
entries[i] = PublishBatchEntry{Topic: "t", DataB64: "AA=="}
}
body, _ := json.Marshal(PublishBatchRequest{Messages: entries})
req := withNamespace(httptest.NewRequest(http.MethodPost, "/v1/pubsub/publish-batch", bytes.NewReader(body)), "ns")
rr := httptest.NewRecorder()
h.PublishBatchHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for oversize batch, got %d", rr.Code)
}
}
func TestPublishBatchHandler_invalid_base64_rejected(t *testing.T) {
h := newTestHandlers(&mockNetworkClient{pubsub: &mockPubSubClient{}})
body, _ := json.Marshal(PublishBatchRequest{Messages: []PublishBatchEntry{
{Topic: "good", DataB64: base64.StdEncoding.EncodeToString([]byte("ok"))},
{Topic: "bad", DataB64: "!!!not-base64"},
}})
req := withNamespace(httptest.NewRequest(http.MethodPost, "/v1/pubsub/publish-batch", bytes.NewReader(body)), "ns")
rr := httptest.NewRecorder()
h.PublishBatchHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid base64, got %d", rr.Code)
}
}
func TestPublishBatchHandler_missing_topic_rejected(t *testing.T) {
h := newTestHandlers(&mockNetworkClient{pubsub: &mockPubSubClient{}})
body, _ := json.Marshal(PublishBatchRequest{Messages: []PublishBatchEntry{
{Topic: "", DataB64: base64.StdEncoding.EncodeToString([]byte("x"))},
}})
req := withNamespace(httptest.NewRequest(http.MethodPost, "/v1/pubsub/publish-batch", bytes.NewReader(body)), "ns")
rr := httptest.NewRecorder()
h.PublishBatchHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for missing topic, got %d", rr.Code)
}
}
func TestPublishBatchHandler_happy_calls_PublishBatch(t *testing.T) {
var (
called int32
gotMessages []client.TopicMessage
mu sync.Mutex
)
mock := &mockPubSubClient{
PublishBatchFunc: func(ctx context.Context, msgs []client.TopicMessage, opts client.PublishBatchOptions) error {
atomic.AddInt32(&called, 1)
mu.Lock()
gotMessages = append(gotMessages, msgs...)
mu.Unlock()
return nil
},
}
h := newTestHandlers(&mockNetworkClient{pubsub: mock})
entries := []PublishBatchEntry{
{Topic: "a", DataB64: base64.StdEncoding.EncodeToString([]byte("data-a"))},
{Topic: "b", DataB64: base64.StdEncoding.EncodeToString([]byte("data-b"))},
}
body, _ := json.Marshal(PublishBatchRequest{Messages: entries})
req := withNamespace(httptest.NewRequest(http.MethodPost, "/v1/pubsub/publish-batch", bytes.NewReader(body)), "test-ns")
rr := httptest.NewRecorder()
h.PublishBatchHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rr.Code, rr.Body.String())
}
// PublishBatch is invoked from a goroutine; give it a moment to run.
deadline := time.Now().Add(2 * time.Second)
for atomic.LoadInt32(&called) == 0 {
if time.Now().After(deadline) {
t.Fatal("PublishBatch was not called within 2s")
}
time.Sleep(10 * time.Millisecond)
}
mu.Lock()
defer mu.Unlock()
if len(gotMessages) != 2 {
t.Fatalf("expected 2 messages forwarded, got %d", len(gotMessages))
}
if gotMessages[0].Topic != "a" || string(gotMessages[0].Data) != "data-a" {
t.Errorf("unexpected first message: %+v", gotMessages[0])
}
if gotMessages[1].Topic != "b" || string(gotMessages[1].Data) != "data-b" {
t.Errorf("unexpected second message: %+v", gotMessages[1])
}
}

View File

@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/DeBrosOfficial/network/pkg/client"
@ -12,6 +13,10 @@ import (
"go.uber.org/zap"
)
// MaxPublishBatchSize is the maximum number of messages allowed in a single
// /v1/pubsub/publish-batch request. Mirrors pubsub.MaxBatchSize.
const MaxPublishBatchSize = pubsub.MaxBatchSize
// PublishHandler handles POST /v1/pubsub/publish {topic, data_base64}
func (p *PubSubHandlers) PublishHandler(w http.ResponseWriter, r *http.Request) {
if p.client == nil {
@ -39,9 +44,133 @@ func (p *PubSubHandlers) PublishHandler(w http.ResponseWriter, r *http.Request)
return
}
// Check for local websocket subscribers FIRST and deliver directly
p.deliverLocal(ns, body.Topic, data)
// Publish to libp2p asynchronously for cross-node delivery.
go func() {
publishCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ctx := pubsub.WithNamespace(client.WithInternalAuth(publishCtx), ns)
if err := p.client.PubSub().Publish(ctx, body.Topic, data); err != nil {
p.logger.ComponentWarn("gateway", "async libp2p publish failed",
zap.String("topic", body.Topic),
zap.Error(err))
}
}()
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
}
// PublishBatchRequest is the request body for POST /v1/pubsub/publish-batch.
type PublishBatchRequest struct {
Messages []PublishBatchEntry `json:"messages"`
BestEffort bool `json:"best_effort,omitempty"`
}
// PublishBatchEntry is one message in a batch publish request.
type PublishBatchEntry struct {
Topic string `json:"topic"`
DataB64 string `json:"data_base64"`
}
// PublishBatchResponse is the response body for /v1/pubsub/publish-batch.
//
// libp2p delivery is asynchronous and not awaited here, mirroring the
// single-publish handler's fire-and-forget contract. Per-topic failures
// are not surfaced via this response — operators should consult logs /
// metrics for delivery health.
type PublishBatchResponse struct {
Status string `json:"status"` // always "ok" — request was accepted
}
// MaxPerMessageBytes caps an individual message payload inside a batch.
// Mirrors the 1MB cap on /v1/pubsub/publish.
const MaxPerMessageBytes = 1 << 20
// PublishBatchHandler handles POST /v1/pubsub/publish-batch.
// Accepts up to MaxPublishBatchSize messages and publishes them in parallel,
// preserving namespace isolation. Local subscribers receive messages
// immediately; libp2p delivery is async.
func (p *PubSubHandlers) PublishBatchHandler(w http.ResponseWriter, r *http.Request) {
if p.client == nil {
writeError(w, http.StatusServiceUnavailable, "client not initialized")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ns := resolveNamespaceFromRequest(r)
if ns == "" {
writeError(w, http.StatusForbidden, "namespace not resolved")
return
}
// Limit body size: MaxPublishBatchSize messages * ~1MB each = up to ~100MB.
// Cap conservatively at 16MB to discourage huge payloads.
r.Body = http.MaxBytesReader(w, r.Body, 16<<20)
var body PublishBatchRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: expected {messages:[{topic,data_base64}]}")
return
}
if len(body.Messages) == 0 {
writeError(w, http.StatusBadRequest, "messages required")
return
}
if len(body.Messages) > MaxPublishBatchSize {
writeError(w, http.StatusBadRequest, "too many messages: max is 100 per batch")
return
}
// Decode all messages up-front so we can fail fast on bad input.
decoded := make([]pubsub.TopicMessage, 0, len(body.Messages))
for i, m := range body.Messages {
if m.Topic == "" {
writeError(w, http.StatusBadRequest, "message missing topic at index "+strconv.Itoa(i))
return
}
data, err := base64.StdEncoding.DecodeString(m.DataB64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid base64 data at index "+strconv.Itoa(i))
return
}
if len(data) > MaxPerMessageBytes {
writeError(w, http.StatusBadRequest, "message too large at index "+strconv.Itoa(i))
return
}
decoded = append(decoded, pubsub.TopicMessage{Topic: m.Topic, Data: data})
}
// Deliver locally + dispatch triggers per topic synchronously (fast in-process).
for _, msg := range decoded {
p.deliverLocal(ns, msg.Topic, msg.Data)
}
// Async libp2p batch publish, similar to PublishHandler's approach.
go func() {
publishCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ctx := pubsub.WithNamespace(client.WithInternalAuth(publishCtx), ns)
opts := pubsub.PublishBatchOptions{BestEffort: body.BestEffort}
err := p.client.PubSub().PublishBatch(ctx, toClientMessages(decoded), clientOpts(opts))
if err != nil {
p.logger.ComponentWarn("gateway", "async libp2p batch publish failed",
zap.Int("messages", len(decoded)),
zap.Error(err))
}
}()
writeJSON(w, http.StatusOK, PublishBatchResponse{Status: "ok"})
}
// deliverLocal handles local-subscriber delivery and fires PubSub triggers.
// It does NOT publish to libp2p — callers handle that themselves (single
// or batched) so this helper stays focused on in-process fan-out.
func (p *PubSubHandlers) deliverLocal(ns, topic string, data []byte) {
p.mu.RLock()
localSubs := p.getLocalSubscribers(body.Topic, ns)
localSubs := p.getLocalSubscribers(topic, ns)
p.mu.RUnlock()
localDeliveryCount := 0
@ -50,48 +179,38 @@ func (p *PubSubHandlers) PublishHandler(w http.ResponseWriter, r *http.Request)
select {
case sub.msgChan <- data:
localDeliveryCount++
p.logger.ComponentDebug("gateway", "delivered to local subscriber",
zap.String("topic", body.Topic))
default:
// Drop if buffer full
p.logger.ComponentWarn("gateway", "local subscriber buffer full, dropping message",
zap.String("topic", body.Topic))
zap.String("topic", topic))
}
}
}
p.logger.ComponentInfo("gateway", "pubsub publish: processing message",
zap.String("topic", body.Topic),
zap.String("topic", topic),
zap.String("namespace", ns),
zap.Int("data_len", len(data)),
zap.Int("local_subscribers", len(localSubs)),
zap.Int("local_delivered", localDeliveryCount))
// Fire PubSub triggers for serverless functions (non-blocking)
// Fire PubSub triggers for serverless functions (non-blocking).
if p.onPublish != nil {
go p.onPublish(context.Background(), ns, body.Topic, data)
go p.onPublish(context.Background(), ns, topic, data)
}
}
// Publish to libp2p asynchronously for cross-node delivery
// This prevents blocking the HTTP response if libp2p network is slow
go func() {
publishCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// toClientMessages converts pubsub.TopicMessage to client.TopicMessage for
// passing through the PubSubClient interface.
func toClientMessages(msgs []pubsub.TopicMessage) []client.TopicMessage {
out := make([]client.TopicMessage, len(msgs))
for i, m := range msgs {
out[i] = client.TopicMessage{Topic: m.Topic, Data: m.Data}
}
return out
}
ctx := pubsub.WithNamespace(client.WithInternalAuth(publishCtx), ns)
if err := p.client.PubSub().Publish(ctx, body.Topic, data); err != nil {
p.logger.ComponentWarn("gateway", "async libp2p publish failed",
zap.String("topic", body.Topic),
zap.Error(err))
} else {
p.logger.ComponentDebug("gateway", "async libp2p publish succeeded",
zap.String("topic", body.Topic))
}
}()
// Return immediately after local delivery
// Local WebSocket subscribers already received the message
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
func clientOpts(o pubsub.PublishBatchOptions) client.PublishBatchOptions {
return client.PublishBatchOptions{BestEffort: o.BestEffort, MaxConcurrency: o.MaxConcurrency}
}
// TopicsHandler lists topics within the caller's namespace

View File

@ -0,0 +1,291 @@
package push
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/DeBrosOfficial/network/pkg/push"
"go.uber.org/zap"
)
// validProviders is the allowlist for the `provider` field on RegisterDevice.
// Keep in sync with what the dispatcher actually has registered at startup.
var validProviders = map[string]struct{}{
"ntfy": {},
"expo": {},
"apns": {}, // future — accepted at registration so apps can pre-flight
}
// MaxTokenBytes caps the device-token length to prevent abuse.
// Real ntfy topic paths and Expo tokens are well under this.
const MaxTokenBytes = 512
// RegisterDeviceHandler handles POST /v1/push/devices.
//
// The caller must be authenticated; their JWT subject (Sub) is used as the
// user_id. API-key callers are allowed only if the body explicitly carries
// a user_id — currently rejected to keep the surface small.
func (h *Handlers) RegisterDeviceHandler(w http.ResponseWriter, r *http.Request) {
if h.store == nil {
writeError(w, http.StatusServiceUnavailable, "push: device store not configured")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ns := resolveNamespace(r)
if ns == "" {
writeError(w, http.StatusForbidden, "namespace not resolved")
return
}
userID := resolveCallerUserID(r)
if userID == "" {
// We require a JWT-authenticated user to bind the device to.
// API-key-only callers can't register devices on behalf of users.
writeError(w, http.StatusUnauthorized, "user authentication required")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 4096)
var body RegisterDeviceRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
body.DeviceID = strings.TrimSpace(body.DeviceID)
body.Provider = strings.TrimSpace(body.Provider)
body.Token = strings.TrimSpace(body.Token)
if body.DeviceID == "" {
writeError(w, http.StatusBadRequest, "device_id required")
return
}
if _, ok := validProviders[body.Provider]; !ok {
writeError(w, http.StatusBadRequest, "unknown provider: "+body.Provider)
return
}
if body.Token == "" {
writeError(w, http.StatusBadRequest, "token required")
return
}
if len(body.Token) > MaxTokenBytes {
writeError(w, http.StatusBadRequest, "token too long")
return
}
now := time.Now().Unix()
dev := push.PushDevice{
Namespace: ns,
UserID: userID,
DeviceID: body.DeviceID,
Provider: body.Provider,
Token: body.Token,
Platform: body.Platform,
AppVer: body.AppVersion,
LastSeen: now,
}
if err := h.store.Upsert(boundCtx(r), dev); err != nil {
h.logger.ComponentWarn("push", "device upsert failed",
zap.String("namespace", ns),
zap.String("user_id", userID),
zap.Error(err))
writeError(w, http.StatusInternalServerError, "registration failed")
return
}
writeJSON(w, http.StatusOK, RegisterDeviceResponse{Status: "ok"})
}
// ListDevicesHandler handles GET /v1/push/devices.
//
// Returns the caller's own devices; tokens are NEVER included in the
// response. Other namespaces / other users are inaccessible.
func (h *Handlers) ListDevicesHandler(w http.ResponseWriter, r *http.Request) {
if h.store == nil {
writeError(w, http.StatusServiceUnavailable, "push: device store not configured")
return
}
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ns := resolveNamespace(r)
if ns == "" {
writeError(w, http.StatusForbidden, "namespace not resolved")
return
}
userID := resolveCallerUserID(r)
if userID == "" {
writeError(w, http.StatusUnauthorized, "user authentication required")
return
}
devs, err := h.store.ListForUser(boundCtx(r), ns, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list failed")
return
}
views := make([]PushDeviceView, len(devs))
for i, d := range devs {
views[i] = PushDeviceView{
ID: d.ID,
DeviceID: d.DeviceID,
Provider: d.Provider,
Platform: d.Platform,
AppVersion: d.AppVer,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
LastSeen: d.LastSeen,
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"devices": views})
}
// DeleteDeviceHandler handles DELETE /v1/push/devices/{id}.
//
// `{id}` is the database row ID returned at registration / by ListDevices.
// Only devices belonging to the caller (matched by namespace + user_id +
// the device ID lookup) can be deleted.
func (h *Handlers) DeleteDeviceHandler(w http.ResponseWriter, r *http.Request) {
if h.store == nil {
writeError(w, http.StatusServiceUnavailable, "push: device store not configured")
return
}
if r.Method != http.MethodDelete {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ns := resolveNamespace(r)
if ns == "" {
writeError(w, http.StatusForbidden, "namespace not resolved")
return
}
userID := resolveCallerUserID(r)
if userID == "" {
writeError(w, http.StatusUnauthorized, "user authentication required")
return
}
id := extractIDFromPath(r.URL.Path, "/v1/push/devices/")
if id == "" {
writeError(w, http.StatusBadRequest, "device id required in path")
return
}
// Authorization check: confirm the device belongs to the caller.
devs, err := h.store.ListForUser(boundCtx(r), ns, userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "lookup failed")
return
}
owns := false
for _, d := range devs {
if d.ID == id {
owns = true
break
}
}
if !owns {
// 404, not 403 — don't leak whether the ID exists in another scope.
writeError(w, http.StatusNotFound, "not found")
return
}
if err := h.store.Delete(boundCtx(r), ns, id); err != nil {
h.logger.ComponentWarn("push", "device delete failed",
zap.String("namespace", ns),
zap.String("device_row_id", id),
zap.Error(err))
writeError(w, http.StatusInternalServerError, "delete failed")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// SendHandler handles POST /v1/push/send.
//
// SECURITY: this endpoint sends arbitrary push messages to any user_id
// in the caller's namespace. It MUST be gated to a small set of trusted
// callers — typically only the namespace's own serverless functions
// (which can send via the WASM `push_send` hostfunc directly without
// going through HTTP) and the namespace operator.
//
// The current implementation accepts any JWT-authenticated caller within
// the namespace. **Add an explicit allow-list or admin-scope check before
// exposing this in production.** The WASM hostfunc bypasses this issue
// because trigger registration already gates which functions exist.
func (h *Handlers) SendHandler(w http.ResponseWriter, r *http.Request) {
if h.dispatcher == nil {
writeError(w, http.StatusServiceUnavailable, "push: dispatcher not configured")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ns := resolveNamespace(r)
if ns == "" {
writeError(w, http.StatusForbidden, "namespace not resolved")
return
}
if resolveCallerUserID(r) == "" {
writeError(w, http.StatusUnauthorized, "user authentication required")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 64*1024) // generous for Data payloads
var body SendRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
body.UserID = strings.TrimSpace(body.UserID)
if body.UserID == "" {
writeError(w, http.StatusBadRequest, "user_id required")
return
}
msg := push.PushMessage{
Title: body.Title,
Body: body.Body,
Channel: body.Channel,
Priority: pickPriority(body.Priority),
Badge: body.Badge,
Sound: body.Sound,
Data: body.Data,
}
if err := h.dispatcher.SendToUser(boundCtx(r), ns, body.UserID, msg); err != nil {
// Treat as non-fatal: some devices may have failed but others may
// have succeeded. Surface as 502 to signal partial trouble; logs
// have the per-device detail.
h.logger.ComponentWarn("push", "send to user partially failed",
zap.String("namespace", ns),
zap.String("user_id", body.UserID),
zap.Error(err))
writeError(w, http.StatusBadGateway, "one or more devices failed")
return
}
writeJSON(w, http.StatusOK, SendResponse{Status: "ok"})
}
// extractIDFromPath returns the trailing path segment after `prefix`, or
// empty string if the path doesn't match. Used because the gateway uses
// the standard `net/http` mux which doesn't extract path params.
func extractIDFromPath(urlPath, prefix string) string {
if !strings.HasPrefix(urlPath, prefix) {
return ""
}
rest := urlPath[len(prefix):]
// Drop any query string (shouldn't normally appear in path here).
if i := strings.IndexAny(rest, "?#/"); i >= 0 {
rest = rest[:i]
}
return rest
}

View File

@ -0,0 +1,330 @@
package push
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
authsvc "github.com/DeBrosOfficial/network/pkg/gateway/auth"
"github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys"
"github.com/DeBrosOfficial/network/pkg/logging"
"github.com/DeBrosOfficial/network/pkg/push"
"go.uber.org/zap"
)
// fakeStore is an in-memory PushDeviceStore for tests.
type fakeStore struct {
devices []push.PushDevice
upsertFn func(push.PushDevice) error
deleteFn func(ns, id string) error
listErr error
}
func (s *fakeStore) Upsert(ctx context.Context, dev push.PushDevice) error {
if s.upsertFn != nil {
return s.upsertFn(dev)
}
if dev.ID == "" {
dev.ID = "row-" + dev.DeviceID
}
s.devices = append(s.devices, dev)
return nil
}
func (s *fakeStore) Delete(ctx context.Context, ns, id string) error {
if s.deleteFn != nil {
return s.deleteFn(ns, id)
}
for i, d := range s.devices {
if d.ID == id && d.Namespace == ns {
s.devices = append(s.devices[:i], s.devices[i+1:]...)
return nil
}
}
return errors.New("not found")
}
func (s *fakeStore) ListForUser(ctx context.Context, ns, userID string) ([]push.PushDevice, error) {
if s.listErr != nil {
return nil, s.listErr
}
out := []push.PushDevice{}
for _, d := range s.devices {
if d.Namespace == ns && d.UserID == userID {
out = append(out, d)
}
}
return out, nil
}
// withAuth populates the namespace + JWT claims (caller user ID).
func withAuth(r *http.Request, namespace, userID string) *http.Request {
ctx := r.Context()
if namespace != "" {
ctx = context.WithValue(ctx, ctxkeys.NamespaceOverride, namespace)
}
if userID != "" {
ctx = context.WithValue(ctx, ctxkeys.JWT, &authsvc.JWTClaims{Sub: userID, Namespace: namespace})
}
return r.WithContext(ctx)
}
func newHandlers(store push.PushDeviceStore, dispatcher *push.PushDispatcher) *Handlers {
logger := &logging.ColoredLogger{Logger: zap.NewNop()}
return NewHandlers(dispatcher, store, logger)
}
// --- RegisterDeviceHandler ---
func TestRegister_happy_path(t *testing.T) {
store := &fakeStore{}
h := newHandlers(store, nil)
body, _ := json.Marshal(RegisterDeviceRequest{
DeviceID: "iphone-abc",
Provider: "ntfy",
Token: "ns/myapp/user-1",
Platform: "ios",
})
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/devices", bytes.NewReader(body)), "myapp", "user-1")
rr := httptest.NewRecorder()
h.RegisterDeviceHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rr.Code, rr.Body.String())
}
if len(store.devices) != 1 {
t.Fatalf("expected 1 device stored, got %d", len(store.devices))
}
d := store.devices[0]
if d.Namespace != "myapp" || d.UserID != "user-1" || d.Token != "ns/myapp/user-1" {
t.Errorf("unexpected device: %+v", d)
}
}
func TestRegister_unauthenticated_rejected(t *testing.T) {
h := newHandlers(&fakeStore{}, nil)
body, _ := json.Marshal(RegisterDeviceRequest{DeviceID: "x", Provider: "ntfy", Token: "t"})
// No JWT in context.
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/devices", bytes.NewReader(body)), "ns", "")
rr := httptest.NewRecorder()
h.RegisterDeviceHandler(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rr.Code)
}
}
func TestRegister_unknown_provider_rejected(t *testing.T) {
h := newHandlers(&fakeStore{}, nil)
body, _ := json.Marshal(RegisterDeviceRequest{DeviceID: "x", Provider: "weirdmail", Token: "t"})
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/devices", bytes.NewReader(body)), "ns", "u")
rr := httptest.NewRecorder()
h.RegisterDeviceHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestRegister_oversize_token_rejected(t *testing.T) {
h := newHandlers(&fakeStore{}, nil)
huge := make([]byte, MaxTokenBytes+1)
for i := range huge {
huge[i] = 'a'
}
body, _ := json.Marshal(RegisterDeviceRequest{DeviceID: "x", Provider: "ntfy", Token: string(huge)})
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/devices", bytes.NewReader(body)), "ns", "u")
rr := httptest.NewRecorder()
h.RegisterDeviceHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
func TestRegister_no_store_returns_503(t *testing.T) {
h := newHandlers(nil, nil)
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/devices", bytes.NewReader([]byte(`{}`))), "ns", "u")
rr := httptest.NewRecorder()
h.RegisterDeviceHandler(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", rr.Code)
}
}
// --- ListDevicesHandler ---
func TestList_returns_only_callers_devices_without_tokens(t *testing.T) {
store := &fakeStore{
devices: []push.PushDevice{
{ID: "1", Namespace: "myapp", UserID: "u1", DeviceID: "d1", Provider: "ntfy", Token: "secret-token-1"},
{ID: "2", Namespace: "myapp", UserID: "u1", DeviceID: "d2", Provider: "expo", Token: "secret-token-2"},
{ID: "3", Namespace: "myapp", UserID: "u2", DeviceID: "d3", Provider: "ntfy", Token: "secret-token-3"},
{ID: "4", Namespace: "other", UserID: "u1", DeviceID: "d4", Provider: "ntfy", Token: "secret-token-4"},
},
}
h := newHandlers(store, nil)
req := withAuth(httptest.NewRequest(http.MethodGet, "/v1/push/devices", nil), "myapp", "u1")
rr := httptest.NewRecorder()
h.ListDevicesHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
var resp struct {
Devices []PushDeviceView `json:"devices"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Devices) != 2 {
t.Fatalf("expected 2 devices, got %d", len(resp.Devices))
}
// Tokens must NOT appear in response — they're not even in the struct.
if bytes.Contains(rr.Body.Bytes(), []byte("secret-token")) {
t.Errorf("response leaked a token: %s", rr.Body.String())
}
}
// --- DeleteDeviceHandler ---
func TestDelete_owns_device_succeeds(t *testing.T) {
store := &fakeStore{
devices: []push.PushDevice{
{ID: "row-d1", Namespace: "myapp", UserID: "u1", DeviceID: "d1"},
},
}
h := newHandlers(store, nil)
req := withAuth(httptest.NewRequest(http.MethodDelete, "/v1/push/devices/row-d1", nil), "myapp", "u1")
rr := httptest.NewRecorder()
h.DeleteDeviceHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rr.Code, rr.Body.String())
}
if len(store.devices) != 0 {
t.Errorf("expected device removed")
}
}
func TestDelete_other_users_device_returns_404(t *testing.T) {
store := &fakeStore{
devices: []push.PushDevice{
{ID: "row-d1", Namespace: "myapp", UserID: "other-user", DeviceID: "d1"},
},
}
h := newHandlers(store, nil)
req := withAuth(httptest.NewRequest(http.MethodDelete, "/v1/push/devices/row-d1", nil), "myapp", "u1")
rr := httptest.NewRecorder()
h.DeleteDeviceHandler(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", rr.Code)
}
if len(store.devices) != 1 {
t.Errorf("expected device NOT removed")
}
}
func TestDelete_missing_id_returns_400(t *testing.T) {
h := newHandlers(&fakeStore{}, nil)
req := withAuth(httptest.NewRequest(http.MethodDelete, "/v1/push/devices/", nil), "myapp", "u1")
rr := httptest.NewRecorder()
h.DeleteDeviceHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
// --- SendHandler ---
func TestSend_dispatcher_called_for_user(t *testing.T) {
var sent int32
dispatcher := push.New(&fakeStore{
devices: []push.PushDevice{
{ID: "row-1", Namespace: "myapp", UserID: "target-user", Provider: "fake", Token: "tok"},
},
}, zap.NewNop())
dispatcher.Register(&fakePushProvider{
name: "fake",
fn: func(ctx context.Context, msg push.PushMessage) error { atomic.AddInt32(&sent, 1); return nil },
})
h := newHandlers(&fakeStore{}, dispatcher)
body, _ := json.Marshal(SendRequest{
UserID: "target-user", Title: "hi", Body: "world",
})
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/send", bytes.NewReader(body)), "myapp", "u1")
rr := httptest.NewRecorder()
h.SendHandler(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body: %s)", rr.Code, rr.Body.String())
}
if atomic.LoadInt32(&sent) != 1 {
t.Errorf("expected provider called once, got %d", sent)
}
}
func TestSend_no_dispatcher_returns_503(t *testing.T) {
h := newHandlers(&fakeStore{}, nil)
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/send", bytes.NewReader([]byte(`{"user_id":"u"}`))), "myapp", "u1")
rr := httptest.NewRecorder()
h.SendHandler(rr, req)
if rr.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", rr.Code)
}
}
func TestSend_missing_user_id_returns_400(t *testing.T) {
dispatcher := push.New(&fakeStore{}, zap.NewNop())
h := newHandlers(&fakeStore{}, dispatcher)
body, _ := json.Marshal(SendRequest{})
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/send", bytes.NewReader(body)), "myapp", "u1")
rr := httptest.NewRecorder()
h.SendHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rr.Code)
}
}
// --- helpers ---
type fakePushProvider struct {
name string
fn func(ctx context.Context, msg push.PushMessage) error
}
func (p *fakePushProvider) Name() string { return p.name }
func (p *fakePushProvider) Send(ctx context.Context, msg push.PushMessage) error {
if p.fn != nil {
return p.fn(ctx, msg)
}
return nil
}
func TestExtractIDFromPath(t *testing.T) {
cases := []struct {
path, prefix, want string
}{
{"/v1/push/devices/abc", "/v1/push/devices/", "abc"},
{"/v1/push/devices/abc?x=1", "/v1/push/devices/", "abc"},
{"/v1/push/devices/", "/v1/push/devices/", ""},
{"/v1/other/abc", "/v1/push/devices/", ""},
}
for _, c := range cases {
if got := extractIDFromPath(c.path, c.prefix); got != c.want {
t.Errorf("extractIDFromPath(%q, %q) = %q, want %q", c.path, c.prefix, got, c.want)
}
}
}

View File

@ -0,0 +1,150 @@
// Package push provides HTTP handlers for managing push-notification
// device registrations and sending pushes.
//
// Endpoints:
//
// GET /v1/push/devices — list caller's registered devices (tokens omitted)
// POST /v1/push/devices — register / update a device
// DELETE /v1/push/devices/{id} — unregister a device
// POST /v1/push/send — send a push to a user (admin/internal scope)
//
// Device tokens are stored AES-256-GCM-encrypted in RQLite via the
// pkg/push.RqliteDeviceStore. Tokens are NEVER returned by any endpoint —
// the GET endpoint omits the token field for safety.
package push
import (
"context"
"encoding/json"
"net/http"
"github.com/DeBrosOfficial/network/pkg/gateway/auth"
"github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys"
"github.com/DeBrosOfficial/network/pkg/logging"
"github.com/DeBrosOfficial/network/pkg/push"
)
// Handlers serves the /v1/push/* HTTP endpoints. Construct via NewHandlers;
// it's safe for concurrent use.
type Handlers struct {
dispatcher *push.PushDispatcher
store push.PushDeviceStore
logger *logging.ColoredLogger
}
// NewHandlers constructs a Handlers. Either argument may be nil — in which
// case the corresponding endpoints return 503 Service Unavailable.
func NewHandlers(dispatcher *push.PushDispatcher, store push.PushDeviceStore, logger *logging.ColoredLogger) *Handlers {
return &Handlers{
dispatcher: dispatcher,
store: store,
logger: logger,
}
}
// RegisterDeviceRequest is the body of POST /v1/push/devices.
//
// `device_id` is an app-supplied stable identifier (e.g. the OS-assigned
// device UUID). Combined with (namespace, user_id) it uniquely identifies
// the registration; re-posting with the same device_id updates the token.
//
// `token` is provider-specific:
// - ntfy: the topic path the device subscribes to (e.g. "ns/myapp/user-1")
// - expo: an ExponentPushToken[...]
// - apns: a hex APNs device token (future)
type RegisterDeviceRequest struct {
DeviceID string `json:"device_id"`
Provider string `json:"provider"` // "ntfy" | "expo" | "apns"
Token string `json:"token"`
Platform string `json:"platform,omitempty"` // "ios" | "android" | "web"
AppVersion string `json:"app_version,omitempty"`
}
// RegisterDeviceResponse is the body of POST /v1/push/devices.
type RegisterDeviceResponse struct {
Status string `json:"status"`
}
// PushDeviceView is the safe (token-omitting) representation returned
// by GET /v1/push/devices.
type PushDeviceView struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
Provider string `json:"provider"`
Platform string `json:"platform,omitempty"`
AppVersion string `json:"app_version,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
LastSeen int64 `json:"last_seen,omitempty"`
}
// SendRequest is the body of POST /v1/push/send.
//
// The dispatcher fans out to all of `user_id`'s registered devices in
// the caller's namespace. Auth scope: see SendHandler — currently
// requires the caller to act on behalf of their own namespace; finer
// per-user authorization is the app's responsibility.
type SendRequest struct {
UserID string `json:"user_id"`
Title string `json:"title"`
Body string `json:"body"`
Channel string `json:"channel,omitempty"`
Priority string `json:"priority,omitempty"` // "high" | "normal" | "" (default)
Badge int `json:"badge,omitempty"`
Sound string `json:"sound,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
}
// SendResponse is the body of POST /v1/push/send.
type SendResponse struct {
Status string `json:"status"`
}
// resolveNamespace pulls the namespace set by auth middleware out of context.
func resolveNamespace(r *http.Request) string {
if v := r.Context().Value(ctxkeys.NamespaceOverride); v != nil {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// resolveCallerUserID extracts the JWT subject (typically the wallet) of
// the caller, or empty if the request was authenticated by API key only.
func resolveCallerUserID(r *http.Request) string {
if v := r.Context().Value(ctxkeys.JWT); v != nil {
if claims, ok := v.(*auth.JWTClaims); ok && claims != nil {
return claims.Sub
}
}
return ""
}
func writeError(w http.ResponseWriter, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// pickPriority maps the wire-format priority string to the typed enum.
func pickPriority(s string) push.PushPriority {
switch s {
case "high":
return push.PriorityHigh
case "normal":
return push.PriorityNormal
default:
return push.PriorityNormal
}
}
// boundCtx returns a request-scoped context with no extra wrapping;
// kept as a seam for future scope (rate-limit context etc.).
func boundCtx(r *http.Request) context.Context { return r.Context() }

View File

@ -90,10 +90,14 @@ func newTestHandlers(reg serverless.FunctionRegistry) *ServerlessHandlers {
}
return NewServerlessHandlers(
nil, // invoker is nil — we only test paths that don't reach it
nil, // engine
reg,
wsManager,
nil, // triggerStore
nil, // cronStore
nil, // dispatcher
nil, // persistentMgr
nil, // wsBridge
nil, // secretsManager
logger,
)

View File

@ -2,7 +2,9 @@ package serverless
import (
"context"
"errors"
"io"
"net"
"net/http"
"strconv"
"strings"
@ -11,6 +13,31 @@ import (
"github.com/DeBrosOfficial/network/pkg/serverless"
)
// extractRemoteIP returns a best-effort source IP for the request.
// Trusts X-Real-IP / X-Forwarded-For only when the immediate peer is loopback
// or a private address (i.e. behind our own reverse proxy / SNI router).
func extractRemoteIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
peer := net.ParseIP(host)
trustHeaders := peer != nil && (peer.IsLoopback() || peer.IsPrivate())
if trustHeaders {
if v := r.Header.Get("X-Real-IP"); v != "" {
return strings.TrimSpace(v)
}
if v := r.Header.Get("X-Forwarded-For"); v != "" {
// First entry is the original client.
if comma := strings.IndexByte(v, ','); comma >= 0 {
v = v[:comma]
}
return strings.TrimSpace(v)
}
}
return host
}
// InvokeFunction handles POST /v1/functions/{name}/invoke
// Invokes a function with the provided input.
func (h *ServerlessHandlers) InvokeFunction(w http.ResponseWriter, r *http.Request, nameWithNS string, version int) {
@ -57,11 +84,27 @@ func (h *ServerlessHandlers) InvokeFunction(w http.ResponseWriter, r *http.Reque
Input: input,
TriggerType: serverless.TriggerTypeHTTP,
CallerWallet: callerWallet,
CallerIP: extractRemoteIP(r),
CallerClaims: h.getCallerClaimsFromRequest(r),
}
resp, err := h.invoker.Invoke(ctx, req)
if err != nil {
statusCode := http.StatusInternalServerError
// Tiered rate limiter returns *RateLimitedError with retry-after.
var rle *serverless.RateLimitedError
if errors.As(err, &rle) {
if rle.RetryAfter > 0 {
w.Header().Set("Retry-After",
strconv.FormatFloat(rle.RetryAfter.Seconds(), 'f', 1, 64))
}
writeJSON(w, http.StatusTooManyRequests, map[string]interface{}{
"error": err.Error(),
"scope": rle.Scope,
"retry_after": rle.RetryAfter.Seconds(),
})
return
}
if serverless.IsNotFound(err) {
statusCode = http.StatusNotFound
} else if serverless.IsResourceExhausted(err) {

View File

@ -14,6 +14,10 @@ func (h *ServerlessHandlers) RegisterRoutes(mux *http.ServeMux) {
// Direct invoke endpoint
mux.HandleFunc("/v1/invoke/", h.HandleInvoke)
// WS connection metrics (operator visibility)
mux.HandleFunc("/v1/serverless/ws/connections", h.WSConnections)
mux.HandleFunc("/v1/serverless/ws/connections/", h.WSConnections)
}
// handleFunctions handles GET /v1/functions (list) and POST /v1/functions (deploy)

View File

@ -91,11 +91,15 @@ func newSecretsTestHandlers(sm serverless.SecretsManager) *ServerlessHandlers {
logger := zap.NewNop()
wsManager := serverless.NewWSManager(logger)
return NewServerlessHandlers(
nil,
nil, // invoker
nil, // engine
newMockRegistry(),
wsManager,
nil,
nil,
nil, // triggerStore
nil, // cronStore
nil, // dispatcher
nil, // persistentMgr
nil, // wsBridge
sm,
logger,
)

View File

@ -10,19 +10,17 @@ import (
"go.uber.org/zap"
)
// addTriggerRequest is the request body for adding a PubSub trigger.
// addTriggerRequest is the request body for adding a PubSub or Cron trigger.
// Exactly one of `topic` or `cron_expression` must be set.
type addTriggerRequest struct {
Topic string `json:"topic"`
Topic string `json:"topic"`
CronExpression string `json:"cron_expression"`
}
// HandleAddTrigger handles POST /v1/functions/{name}/triggers
// Adds a PubSub trigger that invokes this function when a message is published to the topic.
// Branches between PubSub (topic) and Cron (cron_expression) based on the
// request body. Both stores must be wired for their respective branches.
func (h *ServerlessHandlers) HandleAddTrigger(w http.ResponseWriter, r *http.Request, functionName string) {
if h.triggerStore == nil {
writeError(w, http.StatusNotImplemented, "PubSub triggers not available")
return
}
namespace := h.getNamespaceFromRequest(r)
if namespace == "" {
writeError(w, http.StatusBadRequest, "namespace required")
@ -35,15 +33,18 @@ func (h *ServerlessHandlers) HandleAddTrigger(w http.ResponseWriter, r *http.Req
return
}
if req.Topic == "" {
writeError(w, http.StatusBadRequest, "topic required")
if req.Topic == "" && req.CronExpression == "" {
writeError(w, http.StatusBadRequest, "topic or cron_expression required")
return
}
if req.Topic != "" && req.CronExpression != "" {
writeError(w, http.StatusBadRequest, "topic and cron_expression are mutually exclusive")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// Look up function to get its ID
fn, err := h.registry.Get(ctx, namespace, functionName, 0)
if err != nil {
if serverless.IsNotFound(err) {
@ -54,6 +55,38 @@ func (h *ServerlessHandlers) HandleAddTrigger(w http.ResponseWriter, r *http.Req
return
}
if req.CronExpression != "" {
if h.cronStore == nil {
writeError(w, http.StatusNotImplemented, "Cron triggers not available")
return
}
triggerID, err := h.cronStore.Add(ctx, fn.ID, req.CronExpression)
if err != nil {
h.logger.Error("Failed to add Cron trigger",
zap.String("function", functionName),
zap.String("cron_expression", req.CronExpression),
zap.Error(err),
)
writeError(w, http.StatusBadRequest, "Failed to add trigger: "+err.Error())
return
}
h.logger.Info("Cron trigger added via API",
zap.String("function", functionName),
zap.String("cron_expression", req.CronExpression),
zap.String("trigger_id", triggerID),
)
writeJSON(w, http.StatusCreated, map[string]interface{}{
"trigger_id": triggerID,
"function": functionName,
"cron_expression": req.CronExpression,
})
return
}
if h.triggerStore == nil {
writeError(w, http.StatusNotImplemented, "PubSub triggers not available")
return
}
triggerID, err := h.triggerStore.Add(ctx, fn.ID, req.Topic)
if err != nil {
h.logger.Error("Failed to add PubSub trigger",
@ -64,18 +97,14 @@ func (h *ServerlessHandlers) HandleAddTrigger(w http.ResponseWriter, r *http.Req
writeError(w, http.StatusInternalServerError, "Failed to add trigger: "+err.Error())
return
}
// Invalidate cache for this topic
if h.dispatcher != nil {
h.dispatcher.InvalidateCache(ctx, namespace, req.Topic)
}
h.logger.Info("PubSub trigger added via API",
zap.String("function", functionName),
zap.String("topic", req.Topic),
zap.String("trigger_id", triggerID),
)
writeJSON(w, http.StatusCreated, map[string]interface{}{
"trigger_id": triggerID,
"function": functionName,
@ -84,10 +113,12 @@ func (h *ServerlessHandlers) HandleAddTrigger(w http.ResponseWriter, r *http.Req
}
// HandleListTriggers handles GET /v1/functions/{name}/triggers
// Lists all PubSub triggers for a function.
// Returns the merged set of PubSub and Cron triggers for a function.
// Each row carries enough metadata for the CLI's `triggers list` to render
// it; the kind is implied by which fields are populated (Topic vs CronExpression).
func (h *ServerlessHandlers) HandleListTriggers(w http.ResponseWriter, r *http.Request, functionName string) {
if h.triggerStore == nil {
writeError(w, http.StatusNotImplemented, "PubSub triggers not available")
if h.triggerStore == nil && h.cronStore == nil {
writeError(w, http.StatusNotImplemented, "Triggers not available")
return
}
@ -100,7 +131,6 @@ func (h *ServerlessHandlers) HandleListTriggers(w http.ResponseWriter, r *http.R
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// Look up function to get its ID
fn, err := h.registry.Get(ctx, namespace, functionName, 0)
if err != nil {
if serverless.IsNotFound(err) {
@ -111,23 +141,53 @@ func (h *ServerlessHandlers) HandleListTriggers(w http.ResponseWriter, r *http.R
return
}
triggers, err := h.triggerStore.ListByFunction(ctx, fn.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to list triggers")
return
merged := []map[string]interface{}{}
if h.triggerStore != nil {
pubsubTriggers, err := h.triggerStore.ListByFunction(ctx, fn.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to list pubsub triggers")
return
}
for _, t := range pubsubTriggers {
merged = append(merged, map[string]interface{}{
"id": t.ID,
"kind": "pubsub",
"topic": t.Topic,
"enabled": t.Enabled,
})
}
}
if h.cronStore != nil {
cronTriggers, err := h.cronStore.ListByFunction(ctx, fn.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to list cron triggers")
return
}
for _, t := range cronTriggers {
merged = append(merged, map[string]interface{}{
"id": t.ID,
"kind": "cron",
"cron_expression": t.CronExpression,
"next_run_at": t.NextRunAt,
"last_run_at": t.LastRunAt,
"enabled": t.Enabled,
})
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"triggers": triggers,
"count": len(triggers),
"triggers": merged,
"count": len(merged),
})
}
// HandleDeleteTrigger handles DELETE /v1/functions/{name}/triggers/{triggerID}
// Removes a PubSub trigger.
// Removes either a PubSub or Cron trigger. Tries PubSub first (the more
// common case) and falls back to Cron — trigger IDs are UUIDs and can't
// collide between stores, so order is just an optimisation.
func (h *ServerlessHandlers) HandleDeleteTrigger(w http.ResponseWriter, r *http.Request, functionName, triggerID string) {
if h.triggerStore == nil {
writeError(w, http.StatusNotImplemented, "PubSub triggers not available")
if h.triggerStore == nil && h.cronStore == nil {
writeError(w, http.StatusNotImplemented, "Triggers not available")
return
}
@ -140,7 +200,6 @@ func (h *ServerlessHandlers) HandleDeleteTrigger(w http.ResponseWriter, r *http.
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// Look up the trigger's topic before deleting (for cache invalidation)
fn, err := h.registry.Get(ctx, namespace, functionName, 0)
if err != nil {
if serverless.IsNotFound(err) {
@ -151,38 +210,47 @@ func (h *ServerlessHandlers) HandleDeleteTrigger(w http.ResponseWriter, r *http.
return
}
// Get current triggers to find the topic for cache invalidation
triggers, err := h.triggerStore.ListByFunction(ctx, fn.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Failed to look up triggers")
return
}
// Find the topic for the trigger being deleted
// Walk the PubSub list first to capture the topic for cache invalidation.
var triggerTopic string
for _, t := range triggers {
if t.ID == triggerID {
triggerTopic = t.Topic
break
if h.triggerStore != nil {
triggers, listErr := h.triggerStore.ListByFunction(ctx, fn.ID)
if listErr == nil {
for _, t := range triggers {
if t.ID == triggerID {
triggerTopic = t.Topic
break
}
}
}
}
if err := h.triggerStore.Remove(ctx, triggerID); err != nil {
writeError(w, http.StatusInternalServerError, "Failed to remove trigger: "+err.Error())
if triggerTopic != "" {
if err := h.triggerStore.Remove(ctx, triggerID); err != nil {
writeError(w, http.StatusInternalServerError, "Failed to remove trigger: "+err.Error())
return
}
if h.dispatcher != nil {
h.dispatcher.InvalidateCache(ctx, namespace, triggerTopic)
}
h.logger.Info("PubSub trigger removed via API",
zap.String("function", functionName),
zap.String("trigger_id", triggerID),
)
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Trigger removed"})
return
}
// Invalidate cache for the topic
if h.dispatcher != nil && triggerTopic != "" {
h.dispatcher.InvalidateCache(ctx, namespace, triggerTopic)
// Not a PubSub trigger — try cron.
if h.cronStore != nil {
if err := h.cronStore.Remove(ctx, triggerID); err == nil {
h.logger.Info("Cron trigger removed via API",
zap.String("function", functionName),
zap.String("trigger_id", triggerID),
)
writeJSON(w, http.StatusOK, map[string]interface{}{"message": "Trigger removed"})
return
}
}
h.logger.Info("PubSub trigger removed via API",
zap.String("function", functionName),
zap.String("trigger_id", triggerID),
)
writeJSON(w, http.StatusOK, map[string]interface{}{
"message": "Trigger removed",
})
writeError(w, http.StatusNotFound, "Trigger not found")
}

View File

@ -6,7 +6,9 @@ import (
"github.com/DeBrosOfficial/network/pkg/gateway/auth"
"github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys"
"github.com/DeBrosOfficial/network/pkg/serverless"
"github.com/DeBrosOfficial/network/pkg/serverless/persistent"
"github.com/DeBrosOfficial/network/pkg/serverless/triggers"
"github.com/DeBrosOfficial/network/pkg/serverless/wsbridge"
"go.uber.org/zap"
)
@ -14,30 +16,47 @@ import (
// It's a separate struct to keep the Gateway struct clean.
type ServerlessHandlers struct {
invoker *serverless.Invoker
engine *serverless.Engine // for persistent WS instantiation
registry serverless.FunctionRegistry
wsManager *serverless.WSManager
triggerStore *triggers.PubSubTriggerStore
cronStore *triggers.CronTriggerStore // optional; nil = cron triggers unavailable
dispatcher *triggers.PubSubDispatcher
persistentMgr *persistent.Manager // optional; when nil persistent WS rejects 503
wsBridge *wsbridge.Bridge // optional; nil = no client→ns registration
secretsManager serverless.SecretsManager
logger *zap.Logger
}
// NewServerlessHandlers creates a new ServerlessHandlers instance.
//
// engine, persistentMgr, and wsBridge may be nil — persistent-WS
// functions then return 503 on upgrade, and bridged WS clients can't
// be tracked (the host call returns "unknown client_id"). All other
// endpoints continue to work via the invoker.
func NewServerlessHandlers(
invoker *serverless.Invoker,
engine *serverless.Engine,
registry serverless.FunctionRegistry,
wsManager *serverless.WSManager,
triggerStore *triggers.PubSubTriggerStore,
cronStore *triggers.CronTriggerStore,
dispatcher *triggers.PubSubDispatcher,
persistentMgr *persistent.Manager,
wsBridge *wsbridge.Bridge,
secretsManager serverless.SecretsManager,
logger *zap.Logger,
) *ServerlessHandlers {
return &ServerlessHandlers{
invoker: invoker,
engine: engine,
registry: registry,
wsManager: wsManager,
triggerStore: triggerStore,
cronStore: cronStore,
dispatcher: dispatcher,
persistentMgr: persistentMgr,
wsBridge: wsBridge,
secretsManager: secretsManager,
logger: logger,
}
@ -75,6 +94,25 @@ func (h *ServerlessHandlers) getNamespaceFromRequest(r *http.Request) string {
return "default"
}
// getCallerClaimsFromRequest returns the JWT custom claims for the caller,
// or nil if the request was not JWT-authenticated. The map is safe to share
// (read-only on the engine side); we copy to avoid retaining the JWT struct.
func (h *ServerlessHandlers) getCallerClaimsFromRequest(r *http.Request) map[string]string {
v := r.Context().Value(ctxkeys.JWT)
if v == nil {
return nil
}
claims, ok := v.(*auth.JWTClaims)
if !ok || claims == nil || len(claims.Custom) == 0 {
return nil
}
out := make(map[string]string, len(claims.Custom))
for k, val := range claims.Custom {
out[k] = val
}
return out
}
// getWalletFromRequest extracts wallet address from JWT.
func (h *ServerlessHandlers) getWalletFromRequest(r *http.Request) string {
// Import strings package functions inline to avoid circular dependencies

View File

@ -40,6 +40,10 @@ func checkWSOrigin(r *http.Request) bool {
// HandleWebSocket handles WebSocket connections for function streaming.
// It upgrades HTTP connections to WebSocket and manages bi-directional communication
// for real-time function invocation and streaming responses.
//
// Routes to one of two execution models based on function metadata:
// - WSPersistent=true: persistent per-connection WASM instance (plan 06)
// - WSPersistent=false (default): per-frame stateless invocation
func (h *ServerlessHandlers) HandleWebSocket(w http.ResponseWriter, r *http.Request, name string, version int) {
namespace := r.URL.Query().Get("namespace")
if namespace == "" {
@ -51,6 +55,15 @@ func (h *ServerlessHandlers) HandleWebSocket(w http.ResponseWriter, r *http.Requ
return
}
// Look up the function once to decide which execution model to use.
fn, lookupErr := h.registry.Get(r.Context(), namespace, name, version)
if lookupErr == nil && fn != nil && fn.WSPersistent {
h.handlePersistentWebSocket(w, r, fn, namespace)
return
}
// (lookup error not fatal — fall through; per-frame path's invoker will
// re-resolve and surface a proper error.)
// Upgrade to WebSocket
upgrader := websocket.Upgrader{
CheckOrigin: checkWSOrigin,
@ -69,12 +82,49 @@ func (h *ServerlessHandlers) HandleWebSocket(w http.ResponseWriter, r *http.Requ
h.wsManager.Register(clientID, wsConn)
defer h.wsManager.Unregister(clientID)
// Track client → namespace for ws_pubsub_bridge auth checks, and
// auto-clean any bridged topics when the connection ends.
if h.wsBridge != nil {
h.wsBridge.SetClientNamespace(clientID, namespace)
defer h.wsBridge.RemoveClient(context.Background(), clientID)
}
// Server-side keepalive: ping every 30s, expect pong within 60s.
// Without this, a half-open TCP can hang for 2h before the OS notices.
const (
pingInterval = 30 * time.Second
pongWait = 60 * time.Second
)
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
pingDone := make(chan struct{})
go func() {
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
for {
select {
case <-pingDone:
return
case <-ticker.C:
_ = conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second))
}
}
}()
defer close(pingDone)
h.logger.Info("WebSocket connected",
zap.String("client_id", clientID),
zap.String("function", name),
)
callerWallet := h.getWalletFromRequest(r)
callerIP := extractRemoteIP(r)
// Capture custom claims at upgrade time and reuse for every frame —
// the JWT context is request-scoped and won't survive past upgrade.
callerClaims := h.getCallerClaimsFromRequest(r)
// Message loop
for {
@ -85,6 +135,7 @@ func (h *ServerlessHandlers) HandleWebSocket(w http.ResponseWriter, r *http.Requ
}
break
}
h.wsManager.RecordInbound(clientID, len(message))
// Invoke function with WebSocket context
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@ -96,6 +147,8 @@ func (h *ServerlessHandlers) HandleWebSocket(w http.ResponseWriter, r *http.Requ
Input: message,
TriggerType: serverless.TriggerTypeWebSocket,
CallerWallet: callerWallet,
CallerIP: callerIP,
CallerClaims: callerClaims,
WSClientID: clientID,
}

View File

@ -0,0 +1,177 @@
package serverless
import (
"context"
"net/http"
"time"
"github.com/DeBrosOfficial/network/pkg/serverless"
"github.com/DeBrosOfficial/network/pkg/serverless/persistent"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"go.uber.org/zap"
)
// handlePersistentWebSocket runs the per-connection persistent function model.
// One WASM instance is bound to this WS for its entire lifetime. Frames are
// processed serially via the instance's inbound channel.
//
// See plan: core/plans/platform/06_PERSISTENT_WS_FUNCTIONS.md
func (h *ServerlessHandlers) handlePersistentWebSocket(
w http.ResponseWriter, r *http.Request, fn *serverless.Function, namespace string,
) {
// Hard prerequisites — without engine + manager, persistent WS can't run.
if h.engine == nil || h.persistentMgr == nil {
http.Error(w, "persistent WebSocket support not configured", http.StatusServiceUnavailable)
return
}
// Capacity check BEFORE upgrade so we don't leak a half-open WS.
if !h.persistentMgr.Acquire() {
http.Error(w, "gateway at persistent-ws capacity", http.StatusServiceUnavailable)
return
}
releaseSlot := true
defer func() {
if releaseSlot {
h.persistentMgr.Release()
}
}()
upgrader := websocket.Upgrader{CheckOrigin: checkWSOrigin}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
h.logger.Error("persistent WS upgrade failed", zap.Error(err))
return
}
clientID := uuid.New().String()
wsConn := &serverless.GorillaWSConn{Conn: conn}
h.wsManager.Register(clientID, wsConn)
defer h.wsManager.Unregister(clientID)
// Bridge bookkeeping (mirrors stateless path): the persistent WASM
// instance can call ws_pubsub_bridge from ws_open or any frame handler;
// the bridge needs to know which namespace owns this client.
if h.wsBridge != nil {
h.wsBridge.SetClientNamespace(clientID, namespace)
defer h.wsBridge.RemoveClient(context.Background(), clientID)
}
callerWallet := h.getWalletFromRequest(r)
callerIP := extractRemoteIP(r)
callerClaims := h.getCallerClaimsFromRequest(r)
invCtx := &serverless.InvocationContext{
FunctionID: fn.ID,
FunctionName: fn.Name,
Namespace: fn.Namespace,
CallerWallet: callerWallet,
CallerIP: callerIP,
CallerClaims: callerClaims,
WSClientID: clientID,
TriggerType: serverless.TriggerTypeWebSocket,
}
// Instantiate the persistent module. This compiles once (cached) and
// creates one wazero instance bound to this connection.
module, err := h.engine.InstantiatePersistent(r.Context(), fn, invCtx)
if err != nil {
h.logger.Warn("persistent WS instantiate failed",
zap.String("function", fn.Name),
zap.String("namespace", fn.Namespace),
zap.Error(err))
_ = conn.Close()
return
}
inst, err := persistent.NewInstance(module, persistent.Config{
ClientID: clientID,
FunctionName: fn.Name,
Namespace: fn.Namespace,
FrameTimeoutSec: fn.TimeoutSeconds,
MaxInflightFrames: fn.WSMaxInflightPerConn,
}, h.logger)
if err != nil {
h.logger.Warn("persistent WS NewInstance failed",
zap.String("function", fn.Name),
zap.Error(err))
_ = module.Close(context.Background())
_ = conn.Close()
return
}
h.persistentMgr.Register(inst)
// Hand the slot off to instance lifecycle. Released when we Close below.
releaseSlot = false
defer h.persistentMgr.Release()
defer h.persistentMgr.Unregister(clientID)
// ws_open — invoked synchronously. A non-zero return rejects the upgrade.
openInput := persistent.WSOpenInput{
ClientID: clientID,
Wallet: callerWallet,
Namespace: namespace,
}
if err := inst.Open(r.Context(), openInput); err != nil {
h.logger.Info("persistent WS rejected by ws_open",
zap.String("function", fn.Name),
zap.String("client_id", clientID),
zap.Error(err))
inst.Close(context.Background(), persistent.CloseReasonRejected)
_ = conn.Close()
return
}
// Spawn the per-instance frame processor.
runCtx, runCancel := context.WithCancel(context.Background())
go inst.Run(runCtx)
// Server-side keepalive (matches stateless WS handler's behavior).
const (
pingInterval = 30 * time.Second
pongWait = 60 * time.Second
)
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
_ = conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
pingDone := make(chan struct{})
go func() {
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
for {
select {
case <-pingDone:
return
case <-ticker.C:
_ = conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(5*time.Second))
}
}
}()
// Read loop — enqueue frames into the instance.
for {
_, frame, readErr := conn.ReadMessage()
if readErr != nil {
break
}
h.wsManager.RecordInbound(clientID, len(frame))
if err := inst.Submit(frame); err != nil {
h.logger.Warn("persistent WS submit failed (queue full?)",
zap.String("client_id", clientID),
zap.Error(err))
_ = conn.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(1009, "queue full"),
time.Now().Add(time.Second))
break
}
}
// Tear down: stop ping, stop instance Run, invoke ws_close, close WS.
close(pingDone)
runCancel()
inst.Close(context.Background(), persistent.CloseReasonClientDisconnect)
_ = conn.Close()
}

View File

@ -0,0 +1,55 @@
package serverless
import (
"encoding/json"
"net/http"
"strings"
)
// WSConnections handles GET /v1/serverless/ws/connections
// Returns per-connection metrics for all active WS clients on this gateway.
//
// Optional path: /v1/serverless/ws/connections/{client_id} returns a single
// connection's snapshot (404 if not present).
//
// Auth: relies on the existing namespace-ownership middleware. Operators
// inspect their own gateway's connections; per-namespace filtering is not
// applied here because client IDs are gateway-local UUIDs unrelated to
// namespace.
func (h *ServerlessHandlers) WSConnections(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.wsManager == nil {
http.Error(w, "ws manager not initialized", http.StatusServiceUnavailable)
return
}
// Optional trailing path segment = client ID.
const prefix = "/v1/serverless/ws/connections/"
if strings.HasPrefix(r.URL.Path, prefix) {
id := strings.TrimSuffix(r.URL.Path[len(prefix):], "/")
if id == "" {
h.respondJSON(w, http.StatusOK,
map[string]interface{}{"connections": h.wsManager.ListConnStats()})
return
}
stats, ok := h.wsManager.GetConnStats(id)
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
h.respondJSON(w, http.StatusOK, stats)
return
}
h.respondJSON(w, http.StatusOK,
map[string]interface{}{"connections": h.wsManager.ListConnStats()})
}
func (h *ServerlessHandlers) respondJSON(w http.ResponseWriter, status int, body interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}

View File

@ -98,6 +98,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
}
type mockIPFSClient struct {
AddFunc func(ctx context.Context, r io.Reader, filename string) (*ipfs.AddResponse, error)
AddDirectoryFunc func(ctx context.Context, dirPath string) (*ipfs.AddResponse, error)

View File

@ -42,6 +42,11 @@ func (h *WebRTCHandlers) CredentialsHandler(w http.ResponseWriter, r *http.Reque
fmt.Sprintf("turns:%s:5349", h.turnDomain),
)
}
// Stealth: TURNS via the SNI router on :443. Looks like ordinary HTTPS
// to a passive observer / DPI; usable in restricted regions.
if h.stealthCDNDomain != "" {
uris = append(uris, fmt.Sprintf("turns:%s:443", h.stealthCDNDomain))
}
h.logger.ComponentInfo(logging.ComponentGeneral, "Issued TURN credentials",
zap.String("namespace", ns),

View File

@ -17,10 +17,21 @@ type WebRTCHandlers struct {
turnDomain string // TURN server domain for building URIs
turnSecret string // HMAC-SHA1 shared secret for TURN credential generation
// stealthCDNDomain, when non-empty, causes CredentialsHandler to also
// advertise turns://<stealthCDNDomain>:443 — the stealth TURN URI served
// via the in-house SNI router. See pkg/sniproxy.
stealthCDNDomain string
// proxyWebSocket is injected from the gateway to reuse its WebSocket proxy logic
proxyWebSocket func(w http.ResponseWriter, r *http.Request, targetHost string) bool
}
// SetStealthCDNDomain enables the stealth TURN URI in CredentialsHandler.
// Pass empty string to disable. Safe to call before serving begins.
func (h *WebRTCHandlers) SetStealthCDNDomain(domain string) {
h.stealthCDNDomain = domain
}
// NewWebRTCHandlers creates a new WebRTCHandlers instance.
func NewWebRTCHandlers(
logger *logging.ColoredLogger,

View File

@ -12,6 +12,36 @@ import (
// It closes the serverless engine, network client, database connections,
// Olric cache client, and IPFS client in sequence.
func (g *Gateway) Close() {
// Flush PubSub aggregator buffers before tearing down the engine.
// Pending events are dispatched via the invoker which still needs the
// engine to be alive, so this MUST happen before the engine close.
// Aggregator state is local to this node — events not flushed here are
// lost (intended trade-off for high-frequency lossy streams).
if g.pubsubDispatcher != nil {
if agg := g.pubsubDispatcher.Aggregator(); agg != nil {
// 5s budget — same as the engine close timeout below.
// In-flight flushes call back into the invoker which still
// needs the engine to be alive.
if !agg.Shutdown(5 * time.Second) {
g.logger.ComponentWarn(logging.ComponentGeneral,
"PubSub aggregator shutdown timed out; some buffered events may be lost")
}
}
}
// Stop the cron scheduler before tearing down the engine — pending
// invocations call back into the invoker which still needs the engine
// to be alive.
if g.cronScheduler != nil {
g.cronScheduler.Stop()
}
// Drain persistent WebSocket instances. Each instance gets a slice of
// the 30s budget; ws_close on each is best-effort.
if g.persistentWSManager != nil {
g.persistentWSManager.ShutdownAll(30 * time.Second)
}
// Close serverless engine first
if g.serverlessEngine != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)

View File

@ -643,6 +643,12 @@ func requiresNamespaceOwnership(p string) bool {
if strings.HasPrefix(p, "/v1/webrtc/") {
return true
}
if strings.HasPrefix(p, "/v1/push/") {
return true
}
if strings.HasPrefix(p, "/v1/serverless/") {
return true
}
return false
}

View File

@ -119,10 +119,37 @@ func (g *Gateway) Routes() http.Handler {
if g.pubsubHandlers != nil {
mux.HandleFunc("/v1/pubsub/ws", g.pubsubHandlers.WebsocketHandler)
mux.HandleFunc("/v1/pubsub/publish", g.pubsubHandlers.PublishHandler)
mux.HandleFunc("/v1/pubsub/publish-batch", g.pubsubHandlers.PublishBatchHandler)
mux.HandleFunc("/v1/pubsub/topics", g.pubsubHandlers.TopicsHandler)
mux.HandleFunc("/v1/pubsub/presence", g.pubsubHandlers.PresenceHandler)
}
// push notifications
if g.pushHandlers != nil {
// GET + POST share the path; the handler dispatches by method.
mux.HandleFunc("/v1/push/devices", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
g.pushHandlers.ListDevicesHandler(w, r)
case http.MethodPost:
g.pushHandlers.RegisterDeviceHandler(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
// DELETE /v1/push/devices/{id} — uses path-prefix routing because
// net/http mux doesn't extract path params; the handler parses {id}.
mux.HandleFunc("/v1/push/devices/", g.pushHandlers.DeleteDeviceHandler)
mux.HandleFunc("/v1/push/send", g.pushHandlers.SendHandler)
}
// operator node management (wallet JWT auth via middleware)
if g.operatorHandler != nil {
mux.HandleFunc("/v1/operator/invite", g.operatorHandler.HandleInvite)
mux.HandleFunc("/v1/operator/nodes", g.operatorHandler.HandleListNodes)
mux.HandleFunc("/v1/operator/node/register", g.operatorHandler.HandleRegister)
}
// vault proxy (public, rate-limited per identity within handler)
if g.vaultHandlers != nil {
mux.HandleFunc("/v1/vault/push", g.vaultHandlers.HandlePush)

View File

@ -50,7 +50,7 @@ func TestServerlessHandlers_ListFunctions(t *testing.T) {
},
}
h := serverlesshandlers.NewServerlessHandlers(nil, registry, nil, nil, nil, nil, logger)
h := serverlesshandlers.NewServerlessHandlers(nil, nil, registry, nil, nil, nil, nil, nil, nil, nil, logger)
req, _ := http.NewRequest("GET", "/v1/functions?namespace=ns1", nil)
rr := httptest.NewRecorder()
@ -73,7 +73,7 @@ func TestServerlessHandlers_DeployFunction(t *testing.T) {
logger := zap.NewNop()
registry := &mockFunctionRegistry{}
h := serverlesshandlers.NewServerlessHandlers(nil, registry, nil, nil, nil, nil, logger)
h := serverlesshandlers.NewServerlessHandlers(nil, nil, registry, nil, nil, nil, nil, nil, nil, nil, logger)
// Test JSON deploy (which is partially supported according to code)
// Should be 400 because WASM is missing or base64 not supported

View File

@ -88,6 +88,7 @@ func runSSHOnce(ctx context.Context, node Node, command string) SSHResult {
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=10",
"-o", "BatchMode=yes",
"-o", "IdentitiesOnly=yes",
"-i", node.SSHKey,
fmt.Sprintf("%s@%s", node.User, node.Host),
command,

View File

@ -57,6 +57,7 @@ const (
ComponentGateway Component = "GATEWAY"
ComponentSFU Component = "SFU"
ComponentTURN Component = "TURN"
ComponentSNI Component = "SNI"
)
// getComponentColor returns the color for a specific component

View File

@ -1199,12 +1199,26 @@ func (cm *ClusterManager) ProvisionNamespaceCluster(ctx context.Context, namespa
// provisionClusterAsync performs the actual cluster provisioning in the background
func (cm *ClusterManager) provisionClusterAsync(cluster *NamespaceCluster, namespaceID int, namespaceName, provisionedBy string) {
defer func() {
// Recover from panics (e.g., gorqlite index-out-of-range) so the
// goroutine doesn't die silently leaving status stuck at "provisioning".
if r := recover(); r != nil {
cm.logger.Error("Provisioning panicked",
zap.String("namespace", namespaceName),
zap.Any("panic", r),
)
bgCtx := context.Background()
cm.updateClusterStatus(bgCtx, cluster.ID, ClusterStatusFailed,
fmt.Sprintf("provisioning panicked: %v", r))
}
cm.provisioningMu.Lock()
delete(cm.provisioning, namespaceName)
cm.provisioningMu.Unlock()
}()
ctx := context.Background()
// Overall timeout — prevents the goroutine from hanging indefinitely
// if a remote spawn request or RQLite write blocks.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
cm.logger.Info("Starting async cluster provisioning",
zap.String("cluster_id", cluster.ID),

View File

@ -72,6 +72,13 @@ func (m *recoveryMockDB) CreateQueryBuilder(_ string) *rqlite.QueryBuilder {
return nil
}
func (m *recoveryMockDB) Tx(_ context.Context, fn func(tx rqlite.Tx) error) error { return nil }
func (m *recoveryMockDB) Batch(_ context.Context, ops []rqlite.BatchOp) (*rqlite.BatchResult, error) {
return &rqlite.BatchResult{Committed: true, Results: make([]rqlite.OpResult, len(ops))}, nil
}
func (m *recoveryMockDB) BatchWithSeq(_ context.Context, _ string, ops []rqlite.BatchOp) (*rqlite.BatchResult, int64, error) {
res, _ := m.Batch(context.Background(), ops)
return res, 1, nil
}
var _ rqlite.Client = (*recoveryMockDB)(nil)

View File

@ -97,6 +97,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
}
// Ensure mockRQLiteClient implements rqlite.Client
var _ rqlite.Client = (*mockRQLiteClient)(nil)

View File

@ -44,21 +44,29 @@ func (n *Node) registerDNSNode(ctx context.Context) error {
// Determine region (defaulting to "local" for now, could be from cloud metadata in future)
region := "local"
// Read optional metadata from node config
sshUser := n.config.Node.SSHUser
environment := n.config.Node.Environment
operatorWallet := n.config.Node.OperatorWallet
// Insert or update node record
query := `
INSERT INTO dns_nodes (id, ip_address, internal_ip, region, status, last_seen, created_at, updated_at)
VALUES (?, ?, ?, ?, 'active', datetime('now'), datetime('now'), datetime('now'))
INSERT INTO dns_nodes (id, ip_address, internal_ip, region, status, ssh_user, environment, operator_wallet, last_seen, created_at, updated_at)
VALUES (?, ?, ?, ?, 'active', ?, ?, ?, datetime('now'), datetime('now'), datetime('now'))
ON CONFLICT(id) DO UPDATE SET
ip_address = excluded.ip_address,
internal_ip = excluded.internal_ip,
region = excluded.region,
status = 'active',
ssh_user = COALESCE(NULLIF(excluded.ssh_user, ''), dns_nodes.ssh_user),
environment = COALESCE(NULLIF(excluded.environment, ''), dns_nodes.environment),
operator_wallet = COALESCE(NULLIF(excluded.operator_wallet, ''), dns_nodes.operator_wallet),
last_seen = datetime('now'),
updated_at = datetime('now')
`
db := n.rqliteAdapter.GetSQLDB()
_, err = rqlite.SafeExecContext(db, ctx, query, nodeID, ipAddress, internalIP, region)
_, err = rqlite.SafeExecContext(db, ctx, query, nodeID, ipAddress, internalIP, region, sshUser, environment, operatorWallet)
if err != nil {
return fmt.Errorf("failed to register DNS node: %w", err)
}

View File

@ -29,6 +29,18 @@ func (a *ClientAdapter) Publish(ctx context.Context, topic string, data []byte)
return a.manager.Publish(ctx, topic, data)
}
// PublishBatch publishes multiple messages in parallel.
// See Manager.PublishBatch for semantics.
func (a *ClientAdapter) PublishBatch(ctx context.Context, msgs []TopicMessage, opts PublishBatchOptions) error {
return a.manager.PublishBatch(ctx, msgs, opts)
}
// PublishSame sends the same payload to every topic in parallel.
// See Manager.PublishSame for semantics.
func (a *ClientAdapter) PublishSame(ctx context.Context, topics []string, data []byte, opts PublishBatchOptions) error {
return a.manager.PublishSame(ctx, topics, data, opts)
}
// Unsubscribe unsubscribes from a topic
func (a *ClientAdapter) Unsubscribe(ctx context.Context, topic string) error {
return a.manager.Unsubscribe(ctx, topic)

View File

@ -3,9 +3,56 @@ package pubsub
import (
"context"
"fmt"
"strings"
"sync"
"time"
"golang.org/x/sync/errgroup"
)
// defaultBatchConcurrency is the default cap on in-flight publishes within a single batch.
const defaultBatchConcurrency = 32
// MaxBatchSize is the maximum number of messages allowed per PublishBatch call.
// The HTTP handler enforces this; the Manager itself does not, so internal callers
// (e.g. the SDK) can pass larger batches if they accept the responsibility.
const MaxBatchSize = 100
// TopicMessage is one entry in a batch publish.
type TopicMessage struct {
Topic string
Data []byte
}
// PublishBatchOptions controls batch publish behavior.
type PublishBatchOptions struct {
// BestEffort, if true, attempts every publish even when some fail and returns
// a *BatchError summarizing per-topic failures. Default (false) is fail-fast:
// the first failure cancels remaining in-flight publishes and returns that error.
BestEffort bool
// MaxConcurrency caps the number of in-flight publishes within this batch.
// 0 means use defaultBatchConcurrency.
MaxConcurrency int
}
// BatchError aggregates per-topic errors returned when PublishBatch is called
// with BestEffort=true and at least one publish failed.
type BatchError struct {
Errors map[string]error // topic -> error
}
func (e *BatchError) Error() string {
if len(e.Errors) == 0 {
return "batch publish: no errors"
}
names := make([]string, 0, len(e.Errors))
for t := range e.Errors {
names = append(names, t)
}
return fmt.Sprintf("batch publish: %d topic(s) failed: %s", len(e.Errors), strings.Join(names, ", "))
}
// Publish publishes a message to a topic
func (m *Manager) Publish(ctx context.Context, topic string, data []byte) error {
if m.pubsub == nil {
@ -58,3 +105,90 @@ func (m *Manager) Publish(ctx context.Context, topic string, data []byte) error
return nil
}
// PublishBatch publishes multiple messages in parallel, one per topic.
// Default behavior is fail-fast: the first publish error cancels remaining work
// and is returned. If opts.BestEffort is set, every publish is attempted and a
// *BatchError is returned if any failed.
//
// Concurrency is bounded by opts.MaxConcurrency (default 32).
// Empty msgs slice is a no-op and returns nil.
func (m *Manager) PublishBatch(ctx context.Context, msgs []TopicMessage, opts PublishBatchOptions) error {
if len(msgs) == 0 {
return nil
}
if m.pubsub == nil {
return fmt.Errorf("pubsub not initialized")
}
maxConc := opts.MaxConcurrency
if maxConc <= 0 {
maxConc = defaultBatchConcurrency
}
if maxConc > len(msgs) {
maxConc = len(msgs)
}
sem := make(chan struct{}, maxConc)
if !opts.BestEffort {
g, gctx := errgroup.WithContext(ctx)
for _, msg := range msgs {
msg := msg
g.Go(func() error {
select {
case sem <- struct{}{}:
case <-gctx.Done():
return gctx.Err()
}
defer func() { <-sem }()
return m.Publish(gctx, msg.Topic, msg.Data)
})
}
return g.Wait()
}
// Best-effort path: attempt every publish, collect per-topic errors.
var (
wg sync.WaitGroup
errMu sync.Mutex
errMap = map[string]error{}
)
for _, msg := range msgs {
msg := msg
wg.Add(1)
go func() {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
errMu.Lock()
errMap[msg.Topic] = ctx.Err()
errMu.Unlock()
return
}
defer func() { <-sem }()
if err := m.Publish(ctx, msg.Topic, msg.Data); err != nil {
errMu.Lock()
errMap[msg.Topic] = err
errMu.Unlock()
}
}()
}
wg.Wait()
if len(errMap) > 0 {
return &BatchError{Errors: errMap}
}
return nil
}
// PublishSame is a convenience wrapper that sends the same payload to every topic.
func (m *Manager) PublishSame(ctx context.Context, topics []string, data []byte, opts PublishBatchOptions) error {
if len(topics) == 0 {
return nil
}
msgs := make([]TopicMessage, len(topics))
for i, t := range topics {
msgs[i] = TopicMessage{Topic: t, Data: data}
}
return m.PublishBatch(ctx, msgs, opts)
}

Some files were not shown because too many files have changed in this diff Show More