mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-06-16 22:54:12 +00:00
commit
46d511eb5e
4
.gitignore
vendored
4
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
242
core/cmd/sni-router/main.go
Normal 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
187
core/docs/STEALTH_TURN.md
Normal 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.
|
||||
14
core/migrations/020_node_operators.sql
Normal file
14
core/migrations/020_node_operators.sql
Normal 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;
|
||||
28
core/migrations/021_pubsub_trigger_patterns.sql
Normal file
28
core/migrations/021_pubsub_trigger_patterns.sql
Normal 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);
|
||||
20
core/migrations/022_aggregation_windows.sql
Normal file
20
core/migrations/022_aggregation_windows.sql
Normal 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;
|
||||
33
core/migrations/023_push_devices.sql
Normal file
33
core/migrations/023_push_devices.sql
Normal 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);
|
||||
18
core/migrations/024_namespace_publish_seq.sql
Normal file
18
core/migrations/024_namespace_publish_seq.sql
Normal 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
|
||||
);
|
||||
18
core/migrations/025_persistent_ws.sql
Normal file
18
core/migrations/025_persistent_ws.sql
Normal 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;
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
116
core/pkg/cli/cmd/node/migrate_conf.go
Normal file
116
core/pkg/cli/cmd/node/migrate_conf.go
Normal 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)")
|
||||
}
|
||||
@ -32,4 +32,6 @@ func init() {
|
||||
Cmd.AddCommand(recoverRaftCmd)
|
||||
Cmd.AddCommand(enrollCmd)
|
||||
Cmd.AddCommand(unlockCmd)
|
||||
Cmd.AddCommand(migrateConfCmd)
|
||||
Cmd.AddCommand(setupCmd)
|
||||
}
|
||||
|
||||
47
core/pkg/cli/cmd/node/setup.go
Normal file
47
core/pkg/cli/cmd/node/setup.go
Normal 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")
|
||||
}
|
||||
58
core/pkg/cli/cmd/nodescmd/nodes.go
Normal file
58
core/pkg/cli/cmd/nodescmd/nodes.go
Normal 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)")
|
||||
}
|
||||
225
core/pkg/cli/cmd/pushcmd/push.go
Normal file
225
core/pkg/cli/cmd/pushcmd/push.go
Normal 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)
|
||||
}
|
||||
234
core/pkg/cli/cmd/rolloutcmd/rollout.go
Normal file
234
core/pkg/cli/cmd/rolloutcmd/rollout.go
Normal 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)
|
||||
}
|
||||
101
core/pkg/cli/cmd/sshcmd/ssh.go
Normal file
101
core/pkg/cli/cmd/sshcmd/ssh.go
Normal 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()
|
||||
}
|
||||
143
core/pkg/cli/cmd/statuscmd/status.go
Normal file
143
core/pkg/cli/cmd/statuscmd/status.go
Normal 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")
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
131
core/pkg/cli/environment_test.go
Normal file
131
core/pkg/cli/environment_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
120
core/pkg/cli/monitor/alerts_vault_test.go
Normal file
120
core/pkg/cli/monitor/alerts_vault_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
161
core/pkg/cli/noderesolver/resolver.go
Normal file
161
core/pkg/cli/noderesolver/resolver.go
Normal 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
|
||||
}
|
||||
152
core/pkg/cli/noderesolver/resolver_test.go
Normal file
152
core/pkg/cli/noderesolver/resolver_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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"},
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -13,6 +13,7 @@ var coreServices = []string{
|
||||
"orama-olric",
|
||||
"orama-ipfs",
|
||||
"orama-ipfs-cluster",
|
||||
"orama-vault",
|
||||
"orama-anyone-relay",
|
||||
"orama-anyone-client",
|
||||
"coredns",
|
||||
|
||||
@ -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 {
|
||||
|
||||
70
core/pkg/cli/production/report/vault.go
Normal file
70
core/pkg/cli/production/report/vault.go
Normal 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
|
||||
}
|
||||
369
core/pkg/cli/production/setup/command.go
Normal file
369
core/pkg/cli/production/setup/command.go
Normal 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
|
||||
}
|
||||
@ -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)",
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
@ -162,6 +162,7 @@ func GetProductionServices() []string {
|
||||
"orama-olric",
|
||||
"orama-ipfs-cluster",
|
||||
"orama-ipfs",
|
||||
"orama-vault",
|
||||
"orama-anyone-client",
|
||||
"orama-anyone-relay",
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
99
core/pkg/gateway/handlers/operator/handler.go
Normal file
99
core/pkg/gateway/handlers/operator/handler.go
Normal 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})
|
||||
}
|
||||
242
core/pkg/gateway/handlers/operator/handler_test.go
Normal file
242
core/pkg/gateway/handlers/operator/handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
79
core/pkg/gateway/handlers/operator/invite.go
Normal file
79
core/pkg/gateway/handlers/operator/invite.go
Normal 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,
|
||||
})
|
||||
}
|
||||
74
core/pkg/gateway/handlers/operator/nodes.go
Normal file
74
core/pkg/gateway/handlers/operator/nodes.go
Normal 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})
|
||||
}
|
||||
138
core/pkg/gateway/handlers/operator/register.go
Normal file
138
core/pkg/gateway/handlers/operator/register.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
156
core/pkg/gateway/handlers/pubsub/publish_batch_handler_test.go
Normal file
156
core/pkg/gateway/handlers/pubsub/publish_batch_handler_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
291
core/pkg/gateway/handlers/push/handlers.go
Normal file
291
core/pkg/gateway/handlers/push/handlers.go
Normal 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
|
||||
}
|
||||
330
core/pkg/gateway/handlers/push/handlers_test.go
Normal file
330
core/pkg/gateway/handlers/push/handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
150
core/pkg/gateway/handlers/push/types.go
Normal file
150
core/pkg/gateway/handlers/push/types.go
Normal 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() }
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
177
core/pkg/gateway/handlers/serverless/ws_persistent_handler.go
Normal file
177
core/pkg/gateway/handlers/serverless/ws_persistent_handler.go
Normal 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()
|
||||
}
|
||||
55
core/pkg/gateway/handlers/serverless/ws_stats_handler.go
Normal file
55
core/pkg/gateway/handlers/serverless/ws_stats_handler.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user