Merge pull request #90 from DeBrosDAO/nightly

Nightly
This commit is contained in:
anonpenguin 2026-05-12 10:01:25 +03:00 committed by GitHub
commit c172f1355f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
270 changed files with 24120 additions and 4146 deletions

85
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,85 @@
name: CI
on:
push:
branches:
- main
- nightly
pull_request:
branches:
- main
- nightly
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
go-test:
name: Go tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24"
cache-dependency-path: core/go.sum
- name: Vet
working-directory: core
run: go vet ./...
- name: Test
working-directory: core
run: go test -race -timeout 5m ./...
sdk-build:
name: SDK typecheck, build, unit tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: sdk
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm typecheck
- name: Build
run: pnpm build
- name: Unit tests
run: pnpm vitest run tests/unit
version-sanity:
name: Verify VERSION ↔ sdk/package.json sync
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Compare versions
run: |
ROOT=$(tr -d '[:space:]' < VERSION)
SDK=$(node -p "require('./sdk/package.json').version")
if [ "$ROOT" != "$SDK" ]; then
echo "::warning::/VERSION ($ROOT) and sdk/package.json ($SDK) differ. Run 'make -C core bump VER=$ROOT' to sync."
else
echo "Versions in sync: $ROOT"
fi

View File

@ -1,6 +1,8 @@
name: Publish SDK to npm
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
@ -26,6 +28,20 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Verify VERSION file matches release tag
if: github.event_name == 'release'
working-directory: .
run: |
TAG="${{ github.event.release.tag_name }}"
EXPECTED="${TAG#v}"
EXPECTED="${EXPECTED%-nightly}"
ACTUAL=$(tr -d '[:space:]' < VERSION)
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "::error::Tag $TAG implies version '$EXPECTED' but /VERSION says '$ACTUAL'."
echo "::error::Run 'make -C core bump VER=$EXPECTED' and commit before tagging."
exit 1
fi
- name: Set up Node.js
uses: actions/setup-node@v4
with:
@ -41,8 +57,14 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Bump version
if: inputs.version != ''
run: npm version ${{ inputs.version }} --no-git-tag-version
run: |
if [ "${{ github.event_name }}" = "release" ]; then
VERSION="${{ github.event.release.tag_name }}"
VERSION="${VERSION#v}"
npm version "$VERSION" --no-git-tag-version
elif [ -n "${{ inputs.version }}" ]; then
npm version ${{ inputs.version }} --no-git-tag-version
fi
- name: Typecheck
run: pnpm typecheck
@ -60,18 +82,23 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish
if: inputs.dry-run == false
run: npm publish --access public
if: github.event_name == 'release' || inputs.dry-run != true
run: |
if [[ "${{ github.event.release.target_commitish }}" != "main" && "${{ github.event_name }}" == "release" ]]; then
npm publish --access public --tag nightly
else
npm publish --access public
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Get published version
if: inputs.dry-run == false
if: github.event_name == 'release' || inputs.dry-run != true
id: version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Create git tag
if: inputs.dry-run == false
if: github.event_name != 'release' && inputs.dry-run != true
working-directory: .
run: |
git config user.name "github-actions[bot]"

View File

@ -25,6 +25,19 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Verify VERSION file matches release tag
if: github.event_name == 'release'
run: |
TAG="${{ github.event.release.tag_name }}"
EXPECTED="${TAG#v}"
EXPECTED="${EXPECTED%-nightly}"
ACTUAL=$(tr -d '[:space:]' < VERSION)
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "::error::Tag $TAG implies version '$EXPECTED' but /VERSION says '$ACTUAL'."
echo "::error::Run 'make -C core bump VER=$EXPECTED' and commit before tagging."
exit 1
fi
- name: Set up Go
uses: actions/setup-go@v5
with:
@ -58,8 +71,8 @@ jobs:
LDFLAGS="-X 'main.version=$VERSION' -X 'main.commit=$COMMIT' -X 'main.date=$DATE'"
mkdir -p build/usr/local/bin
go build -ldflags "$LDFLAGS" -o build/usr/local/bin/orama cmd/cli/main.go
go build -ldflags "$LDFLAGS" -o build/usr/local/bin/orama-node cmd/node/main.go
go build -ldflags "$LDFLAGS" -o build/usr/local/bin/orama ./cmd/cli
go build -ldflags "$LDFLAGS" -o build/usr/local/bin/orama-node ./cmd/node
# Build the entire gateway package so helper files (e.g., config parsing) are included
go build -ldflags "$LDFLAGS" -o build/usr/local/bin/orama-gateway ./cmd/gateway
@ -111,7 +124,6 @@ jobs:
PKG_NAME="orama_${VERSION}_${ARCH}"
dpkg-deb --build ${PKG_NAME}
mv ${PKG_NAME}.deb orama_${VERSION}_${ARCH}.deb
- name: Upload artifact
uses: actions/upload-artifact@v4

View File

@ -13,29 +13,42 @@ permissions:
jobs:
build-release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Need full history for changelog
- name: Verify VERSION file matches release tag
run: |
TAG="${GITHUB_REF_NAME}"
EXPECTED="${TAG#v}"
EXPECTED="${EXPECTED%-nightly}"
ACTUAL=$(tr -d '[:space:]' < VERSION)
if [ "$EXPECTED" != "$ACTUAL" ]; then
echo "::error::Tag $TAG implies version '$EXPECTED' but /VERSION says '$ACTUAL'."
echo "::error::Run 'make -C core bump VER=$EXPECTED' and commit before tagging."
exit 1
fi
echo "VERSION file matches tag: $ACTUAL"
- name: Set up Go
uses: actions/setup-go@v4
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: core/go.sum
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:

5
.gitignore vendored
View File

@ -25,6 +25,7 @@ Thumbs.db
# === Core (Go) ===
core/phantom-auth/
bin/
core/bin/
core/bin-linux/
core/dist/
@ -65,6 +66,7 @@ go.work
*.db
# === Website ===
website/remote.conf
website/node_modules/
website/dist/
website/invest-api/invest-api
@ -88,3 +90,6 @@ os/output/
.dev/
.local/
local/
# Implementation plans (not committed)
core/plans/

View File

@ -1,7 +1,8 @@
# GoReleaser Configuration for DeBros Network
# Builds and releases orama (CLI) and orama-node binaries
# Publishes to: GitHub Releases, Homebrew, and apt (.deb packages)
# GoReleaser v2 Configuration for DeBros Network
# Builds and releases orama (CLI) and orama-node binaries.
# Publishes to: GitHub Releases, Homebrew (stable only), and apt (.deb packages).
version: 2
project_name: orama-network
env:
@ -9,8 +10,7 @@ env:
before:
hooks:
- cmd: go mod tidy
dir: core
- go -C core mod tidy
builds:
# orama CLI binary
@ -51,9 +51,9 @@ builds:
archives:
# Tar.gz archives for orama CLI
- id: orama-archives
builds:
ids:
- orama
format: tar.gz
formats: [tar.gz]
name_template: "orama_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files:
- README.md
@ -61,9 +61,9 @@ archives:
# Tar.gz archives for orama-node
- id: orama-node-archives
builds:
ids:
- orama-node
format: tar.gz
formats: [tar.gz]
name_template: "orama-node_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files:
- README.md
@ -74,10 +74,10 @@ nfpms:
# orama CLI .deb package
- id: orama-deb
package_name: orama
builds:
ids:
- orama
vendor: DeBros
homepage: https://github.com/DeBrosOfficial/network
homepage: https://github.com/DeBrosDAO/orama
maintainer: DeBros <dev@debros.io>
description: CLI tool for the Orama decentralized network
license: MIT
@ -87,7 +87,7 @@ nfpms:
section: utils
priority: optional
contents:
- src: ./core/README.md
- src: ./README.md
dst: /usr/share/doc/orama/README.md
deb:
lintian_overrides:
@ -96,10 +96,10 @@ nfpms:
# orama-node .deb package
- id: orama-node-deb
package_name: orama-node
builds:
ids:
- orama-node
vendor: DeBros
homepage: https://github.com/DeBrosOfficial/network
homepage: https://github.com/DeBrosDAO/orama
maintainer: DeBros <dev@debros.io>
description: Node daemon for the Orama decentralized network
license: MIT
@ -109,25 +109,28 @@ nfpms:
section: net
priority: optional
contents:
- src: ./core/README.md
- src: ./README.md
dst: /usr/share/doc/orama-node/README.md
deb:
lintian_overrides:
- statically-linked-binary
# Homebrew tap for macOS (orama CLI only)
# Homebrew tap for macOS (orama CLI only).
# Stable releases only — prereleases (nightly) are skipped so we don't
# pollute the tap or fight a 401 on a missing HOMEBREW_TAP_TOKEN.
brews:
- name: orama
ids:
- orama-archives
repository:
owner: DeBrosOfficial
owner: DeBrosDAO
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_TOKEN }}"
folder: Formula
homepage: https://github.com/DeBrosOfficial/network
directory: Formula
homepage: https://github.com/DeBrosDAO/orama
description: CLI tool for the Orama decentralized network
license: MIT
skip_upload: '{{ if .Prerelease }}true{{ else }}false{{ end }}'
install: |
bin.install "orama"
test: |
@ -138,7 +141,7 @@ checksum:
algorithm: sha256
snapshot:
name_template: "{{ incpatch .Version }}-next"
version_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
@ -154,8 +157,8 @@ changelog:
release:
github:
owner: DeBrosOfficial
name: network
owner: DeBrosDAO
name: orama
draft: false
prerelease: auto
name_template: "Release {{.Version}}"

1
VERSION Normal file
View File

@ -0,0 +1 @@
0.122.9

View File

@ -61,9 +61,11 @@ test-e2e-quick:
# Network - Distributed P2P Database System
# Makefile for development and build tasks
.PHONY: build clean test deps tidy fmt vet lint install-hooks push-devnet push-testnet rollout-devnet rollout-testnet release
.PHONY: build clean test deps tidy fmt vet lint install-hooks push-devnet push-testnet rollout-devnet rollout-testnet release bump
VERSION := 0.120.0
# Single source of truth — repo-root VERSION file. Update with `make bump VER=X.Y.Z`
# or by editing /VERSION directly. Release workflows verify this matches the tag.
VERSION := $(shell cat ../VERSION 2>/dev/null | tr -d '[:space:]' || echo unknown)
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 +82,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)
@ -129,6 +132,17 @@ rollout-devnet:
rollout-testnet:
./bin/orama node rollout --env testnet --yes
# Bump the repo-root VERSION file and sync sdk/package.json.
# Usage: make bump VER=0.122.9
bump:
@if [ -z "$(VER)" ]; then \
echo "Usage: make bump VER=X.Y.Z"; exit 1; \
fi
@echo "$(VER)" > ../VERSION
@cd ../sdk && npm version $(VER) --no-git-tag-version > /dev/null
@echo "Bumped VERSION and sdk/package.json to $(VER)"
@echo "Next: git add ../VERSION ../sdk/package.json && git commit -m 'release: $(VER)'"
# Interactive release workflow (tag + push)
release:
@bash scripts/release.sh

BIN
core/cli Executable file

Binary file not shown.

View File

@ -18,7 +18,12 @@ import (
"github.com/DeBrosOfficial/network/pkg/cli/cmd/monitorcmd"
"github.com/DeBrosOfficial/network/pkg/cli/cmd/namespacecmd"
"github.com/DeBrosOfficial/network/pkg/cli/cmd/node"
"github.com/DeBrosOfficial/network/pkg/cli/cmd/nodescmd"
"github.com/DeBrosOfficial/network/pkg/cli/cmd/pushcmd"
"github.com/DeBrosOfficial/network/pkg/cli/cmd/rolloutcmd"
"github.com/DeBrosOfficial/network/pkg/cli/cmd/sandboxcmd"
"github.com/DeBrosOfficial/network/pkg/cli/cmd/sshcmd"
"github.com/DeBrosOfficial/network/pkg/cli/cmd/statuscmd"
)
// version metadata populated via -ldflags at build time
@ -91,6 +96,13 @@ and interacting with the Orama distributed network.`,
// Sandbox command (ephemeral Hetzner Cloud clusters)
rootCmd.AddCommand(sandboxcmd.Cmd)
// Unified node management commands
rootCmd.AddCommand(nodescmd.Cmd)
rootCmd.AddCommand(pushcmd.Cmd)
rootCmd.AddCommand(rolloutcmd.Cmd)
rootCmd.AddCommand(statuscmd.Cmd)
rootCmd.AddCommand(sshcmd.Cmd)
return rootCmd
}

View File

@ -77,21 +77,26 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
}
type yamlCfg struct {
ListenAddr string `yaml:"listen_addr"`
ClientNamespace string `yaml:"client_namespace"`
RQLiteDSN string `yaml:"rqlite_dsn"`
GlobalRQLiteDSN string `yaml:"global_rqlite_dsn"`
Peers []string `yaml:"bootstrap_peers"`
EnableHTTPS bool `yaml:"enable_https"`
DomainName string `yaml:"domain_name"`
TLSCacheDir string `yaml:"tls_cache_dir"`
OlricServers []string `yaml:"olric_servers"`
OlricTimeout string `yaml:"olric_timeout"`
IPFSClusterAPIURL string `yaml:"ipfs_cluster_api_url"`
IPFSAPIURL string `yaml:"ipfs_api_url"`
IPFSTimeout string `yaml:"ipfs_timeout"`
IPFSReplicationFactor int `yaml:"ipfs_replication_factor"`
ListenAddr string `yaml:"listen_addr"`
ClientNamespace string `yaml:"client_namespace"`
RQLiteDSN string `yaml:"rqlite_dsn"`
GlobalRQLiteDSN string `yaml:"global_rqlite_dsn"`
Peers []string `yaml:"bootstrap_peers"`
EnableHTTPS bool `yaml:"enable_https"`
DomainName string `yaml:"domain_name"`
TLSCacheDir string `yaml:"tls_cache_dir"`
OlricServers []string `yaml:"olric_servers"`
OlricTimeout string `yaml:"olric_timeout"`
IPFSClusterAPIURL string `yaml:"ipfs_cluster_api_url"`
IPFSAPIURL string `yaml:"ipfs_api_url"`
IPFSTimeout string `yaml:"ipfs_timeout"`
IPFSReplicationFactor int `yaml:"ipfs_replication_factor"`
WebRTC yamlWebRTCCfg `yaml:"webrtc"`
// ClusterSecretPath: see GatewayYAMLConfig docstring. Optional;
// when set, the standalone gateway reads the file at this path
// and populates cfg.ClusterSecret so JWT signing keys can be
// derived deterministically (bug #215 fix).
ClusterSecretPath string `yaml:"cluster_secret_path"`
}
data, err := os.ReadFile(configPath)
@ -200,6 +205,30 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
cfg.IPFSReplicationFactor = y.IPFSReplicationFactor
}
// Cluster secret — bug #215 fix. The host-managed gateway in
// pkg/node/gateway.go reads this from a known on-disk path; the
// standalone binary (used by namespace gateways via systemd) needs the
// same access so it can derive the cluster-wide Ed25519 JWT signing
// key. Without this, namespace gateways had per-node random keys and
// JWTs minted on one node were unverifiable on another, leaving
// `caller_jwt_subject` empty in serverless host functions.
if path := strings.TrimSpace(y.ClusterSecretPath); path != "" {
secretBytes, err := os.ReadFile(path)
if err != nil {
logger.ComponentError(logging.ComponentGeneral,
"cluster_secret_path is set but the file is unreadable; "+
"JWTs will use a per-node random signing key and will not "+
"verify cross-node — bug #215 will reproduce",
zap.String("path", path),
zap.Error(err))
} else {
cfg.ClusterSecret = strings.TrimSpace(string(secretBytes))
logger.ComponentInfo(logging.ComponentGeneral,
"Loaded cluster secret for cluster-wide JWT signing key derivation",
zap.String("path", path))
}
}
// WebRTC configuration
cfg.WebRTCEnabled = y.WebRTC.Enabled
if y.WebRTC.SFUPort > 0 {

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

@ -0,0 +1,242 @@
// Command sni-router is a TLS-level Server Name Indication router.
//
// It listens on a public TCP port (typically :443), peeks at the TLS
// ClientHello SNI on each connection, and forwards the raw stream to
// a configured backend. It does NOT terminate TLS — encrypted bytes
// pass through verbatim. This lets one port serve multiple TLS-speaking
// backends (HTTPS for the gateway, TURN-over-TLS for stealth WebRTC).
//
// See pkg/sniproxy for the underlying library.
//
// Configuration: YAML file at --config (defaults to ~/.orama/sni-router.yaml).
//
// Example sni-router.yaml:
//
// listen: ":443"
// client_hello_timeout: 5s
// backend_dial_timeout: 5s
// max_concurrent_conns: 10000
// fallback:
// name: caddy
// addr: "127.0.0.1:8443"
// routes:
// - match: "cdn.example.com"
// backend:
// name: turn-tls
// addr: "127.0.0.1:5349"
// - match: "turn.example.com"
// backend:
// name: turn-tls
// addr: "127.0.0.1:5349"
// - match: "*.ns-myapp.example.com"
// backend:
// name: gateway
// addr: "127.0.0.1:8443"
package main
import (
"flag"
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/DeBrosOfficial/network/pkg/config"
"github.com/DeBrosOfficial/network/pkg/logging"
"github.com/DeBrosOfficial/network/pkg/sniproxy"
"go.uber.org/zap"
)
var (
version = "dev"
commit = "unknown"
)
// yamlBackend mirrors sniproxy.Backend for YAML decoding.
type yamlBackend struct {
Name string `yaml:"name"`
Network string `yaml:"network"`
Addr string `yaml:"addr"`
}
// yamlRoute mirrors sniproxy.Route for YAML decoding.
type yamlRoute struct {
Match string `yaml:"match"`
Backend yamlBackend `yaml:"backend"`
}
// yamlConfig is the on-disk configuration shape.
type yamlConfig struct {
Listen string `yaml:"listen"`
ClientHelloTimeout time.Duration `yaml:"client_hello_timeout"`
BackendDialTimeout time.Duration `yaml:"backend_dial_timeout"`
MaxConcurrentConns int `yaml:"max_concurrent_conns"`
Fallback yamlBackend `yaml:"fallback"`
Routes []yamlRoute `yaml:"routes"`
}
func main() {
logger, err := logging.NewColoredLogger(logging.ComponentSNI, true)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to init logger: %v\n", err)
os.Exit(1)
}
logger.ComponentInfo(logging.ComponentSNI, "Starting SNI router",
zap.String("version", version),
zap.String("commit", commit))
cfg := parseConfig(logger)
router := sniproxy.NewRouter(toBackend(cfg.Fallback))
router.Replace(toRoutes(cfg.Routes), toBackend(cfg.Fallback))
srv := sniproxy.NewServer(router, sniproxy.Config{
ClientHelloTimeout: cfg.ClientHelloTimeout,
BackendDialTimeout: cfg.BackendDialTimeout,
MaxConcurrentConns: cfg.MaxConcurrentConns,
}, logger.Logger)
ln, err := net.Listen("tcp", cfg.Listen)
if err != nil {
logger.ComponentError(logging.ComponentSNI, "Failed to listen",
zap.String("addr", cfg.Listen), zap.Error(err))
os.Exit(1)
}
logger.ComponentInfo(logging.ComponentSNI, "SNI router listening",
zap.String("addr", cfg.Listen),
zap.Int("routes", len(cfg.Routes)),
zap.String("fallback", cfg.Fallback.Addr),
)
// Run Serve in a goroutine so the main goroutine can wait on signals.
serveErrCh := make(chan error, 1)
go func() {
serveErrCh <- srv.Serve(ln)
}()
// Wait for termination signal or unrecoverable Serve error.
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
select {
case sig := <-quit:
logger.ComponentInfo(logging.ComponentSNI, "Shutdown signal received",
zap.String("signal", sig.String()))
case err := <-serveErrCh:
logger.ComponentError(logging.ComponentSNI, "Serve returned",
zap.Error(err))
}
// Stop accepting new connections, then drain in-flight ones.
_ = ln.Close()
srv.Close()
logger.ComponentInfo(logging.ComponentSNI, "SNI router shutdown complete")
}
func parseConfig(logger *logging.ColoredLogger) yamlConfig {
configFlag := flag.String("config", "", "Config file path (absolute or filename in ~/.orama)")
flag.Parse()
var configPath string
var err error
if *configFlag != "" {
if filepath.IsAbs(*configFlag) {
configPath = *configFlag
} else {
configPath, err = config.DefaultPath(*configFlag)
if err != nil {
logger.ComponentError(logging.ComponentSNI, "Failed to determine config path",
zap.Error(err))
os.Exit(1)
}
}
} else {
configPath, err = config.DefaultPath("sni-router.yaml")
if err != nil {
logger.ComponentError(logging.ComponentSNI, "Failed to determine config path",
zap.Error(err))
os.Exit(1)
}
}
data, err := os.ReadFile(configPath)
if err != nil {
logger.ComponentError(logging.ComponentSNI, "Config file not found",
zap.String("path", configPath), zap.Error(err))
fmt.Fprintf(os.Stderr, "\nConfig file not found at %s\n", configPath)
os.Exit(1)
}
var y yamlConfig
if err := config.DecodeStrict(strings.NewReader(string(data)), &y); err != nil {
logger.ComponentError(logging.ComponentSNI, "Failed to parse SNI router config",
zap.Error(err))
fmt.Fprintf(os.Stderr, "Configuration parse error: %v\n", err)
os.Exit(1)
}
if errs := validateConfig(&y); len(errs) > 0 {
fmt.Fprintf(os.Stderr, "\nSNI router configuration errors (%d):\n", len(errs))
for _, e := range errs {
fmt.Fprintf(os.Stderr, " - %s\n", e)
}
fmt.Fprintf(os.Stderr, "\nPlease fix the configuration and try again.\n")
os.Exit(1)
}
logger.ComponentInfo(logging.ComponentSNI, "Loaded SNI router configuration",
zap.String("path", configPath),
)
return y
}
// validateConfig returns a non-empty slice of human-readable errors on misconfig.
func validateConfig(y *yamlConfig) []string {
var errs []string
if y.Listen == "" {
errs = append(errs, "listen: required (e.g. \":443\")")
}
if y.Fallback.Addr == "" {
errs = append(errs, "fallback.addr: required (where to send unmatched SNIs, typically Caddy)")
}
for i, r := range y.Routes {
if r.Match == "" {
errs = append(errs, fmt.Sprintf("routes[%d].match: required", i))
}
if r.Backend.Addr == "" {
errs = append(errs, fmt.Sprintf("routes[%d].backend.addr: required", i))
}
}
return errs
}
func toBackend(b yamlBackend) sniproxy.Backend {
network := b.Network
if network == "" {
network = "tcp"
}
return sniproxy.Backend{
Name: b.Name,
Network: network,
Addr: b.Addr,
}
}
func toRoutes(in []yamlRoute) []sniproxy.Route {
out := make([]sniproxy.Route, len(in))
for i, r := range in {
out[i] = sniproxy.Route{
Match: r.Match,
Backend: toBackend(r.Backend),
}
}
return out
}

View File

@ -94,6 +94,46 @@ orama monitor report --env testnet
- **DON'T** clear RQLite data directories unless doing a full cluster rebuild
- **DON'T** use `systemctl stop orama-node` on multiple nodes simultaneously
#### Schema-Migration Ordering Invariant
The gateway binary embeds a set of SQL migrations. The highest-numbered migration is the schema version that binary REQUIRES — **the gateway will refuse to start if its required schema isn't applied** (the schema-version contract added after the 2026-05-06 incident).
This means rolling upgrades have ONE invariant you must respect:
> The new gateway binary's required migrations must be applied to RQLite **before or as part of** starting the new binary on a node.
There are two acceptable patterns:
**Pattern A — let the gateway apply migrations on startup (default).**
The gateway calls `ApplyEmbeddedMigrations` during `NewDependencies` and asserts the schema is at the required version before serving traffic. If the apply succeeds, you're done. If a transient error blocks the apply, gateway startup aborts with a clear `schema mismatch: binary requires version N, database has M` error.
This is the default for both the genesis startup flow and rolling upgrades. No operator action required when it works.
**Pattern B — pre-apply migrations explicitly via the CLI.**
On any node:
```bash
sudo orama node schema status # show binary required vs applied
sudo orama node schema apply --yes # apply pending migrations
```
Then start the new gateway. Useful when you want explicit control during a high-risk upgrade or when the auto-apply path is failing for reasons you want to debug separately.
#### Verifying schema state remotely
Tenants can self-check schema drift without SSH access via:
```
GET /v1/schema-status
```
Returns `{ok, required_version, applied_version, in_sync, pending: [...]}`. The same data is available via `orama node schema status` for operators with shell access.
#### Build-time guard (CI)
`go test ./migrations/` runs a roundtrip test that opens an in-memory SQLite, applies every embedded migration, and exercises representative SQL operations from the platform's Go code. If a Go handler is added that references a column no migration creates, the test fails — drift is caught at PR review time, not at production deploy.
When adding a new platform table or column:
1. Write the migration in `core/migrations/NNN_description.sql`
2. Update the relevant Go code that reads/writes the new column
3. Add an exemplar to `migrations/roundtrip_test.go` mirroring the new SQL — this enforces the contract permanently
#### Recovery from Cluster Split
If nodes get stuck in "Candidate" state or show "leader not found" errors:
@ -449,6 +489,60 @@ sudo cp caddy-root-ca.crt /usr/local/share/ca-certificates/caddy-root-ca.crt
sudo update-ca-certificates
```
## Push notifications
Push provider configuration is **tenant-self-service** as of bug #220
follow-up. Tenants set their own ntfy / Expo credentials via authenticated
HTTP — operators no longer need to edit YAML and restart for every namespace
that wants push.
### Tenant flow (no operator involvement)
```bash
# Set per-namespace config
curl -X PUT https://ns-anchat-test.orama-devnet.network/v1/push/config \
-H 'Authorization: Bearer <user-jwt>' \
-H 'Content-Type: application/json' \
-d '{"ntfy_base_url": "https://ntfy.sh"}'
# Read current config (secrets redacted to booleans)
curl https://ns-anchat-test.orama-devnet.network/v1/push/config \
-H 'Authorization: Bearer <user-jwt>'
# Clear (push reverts to gateway YAML defaults, or 503 if no defaults)
curl -X DELETE https://ns-anchat-test.orama-devnet.network/v1/push/config \
-H 'Authorization: Bearer <user-jwt>'
```
Per-namespace config takes effect on the NEXT push send (the cached
dispatcher is invalidated on PUT/DELETE). No restart needed.
### Operator flow (cluster-wide defaults — optional)
Operators can still seed defaults in the gateway YAML. Per-namespace config
OVERRIDES the defaults; namespaces with no row inherit them.
```yaml
# Cluster-wide push defaults (optional; tenants override per-namespace)
push:
ntfy_base_url: "https://ntfy.sh" # default for namespaces with no override
expo_access_token: "..." # default Expo token
```
### Encryption
Sensitive credentials (`ntfy_auth_token`, `expo_access_token`) are
AES-256-GCM-encrypted at rest in the `namespace_push_config` table using
a key derived from the cluster secret. The GET endpoint returns boolean
`has_X` flags only — credentials are NEVER echoed back over HTTP.
### Disabling push entirely
If `cluster_secret` isn't configured on the gateway, the push subsystem
is disabled and `/v1/push/*` returns 503. To enable: set the cluster secret
and restart. (This is the only operator-side restart still required, and
it's a one-time action at gateway provisioning.)
## Project Structure
See [ARCHITECTURE.md](ARCHITECTURE.md) for the full architecture overview.

View File

@ -42,6 +42,10 @@ name: my-function # Required. Letters, digits, hyphens, underscores.
public: false # Allow unauthenticated invocation (default: false)
memory: 64 # Memory limit in MB (1-256, default: 64)
timeout: 30 # Execution timeout in seconds (1-300, default: 30)
# Bump to 60-300 for batch DB ops, schema migrations,
# or anything that does many sequential host calls.
# Functions that exceed timeout return the canonical
# TIMEOUT envelope: {ok:false, error:{code:"TIMEOUT",...}}.
retry:
count: 0 # Retry attempts on failure (default: 0)
delay: 5 # Seconds between retries (default: 5)
@ -99,15 +103,31 @@ tinygo build -o function.wasm -target wasi function.go
## Host Functions API
Host functions let your WASM code interact with Orama services. They are imported from the `"env"` or `"host"` module (both work) and use a pointer/length ABI for string parameters.
Host functions let your WASM code interact with Orama services. They use a pointer/length ABI for string parameters and are registered at runtime under three module-name aliases — all three resolve to the SAME function table:
All host functions are registered at runtime by the engine. They are available to every function without additional configuration.
| Module name | Status | Use |
|---|---|---|
| `env` | **canonical** | Recommended for new code. Matches the WASI / TinyGo convention used by every example in this doc and the `sdk/fn` package. |
| `host` | alias (kept) | Long-standing alternative; supported indefinitely. |
| `orama` | alias (kept) | Brand-name alias; supported indefinitely so existing code that intuited this name keeps working. |
A function may import any host call from any of the three names interchangeably:
```go
//go:wasmimport env db_query // canonical (preferred)
//go:wasmimport host db_query // identical
//go:wasmimport orama db_query // identical
```
If you see the runtime error `failed to instantiate module: module[X] not instantiated`, your function imported from a name other than the three above — fix the directive. Most functions written using the [`sdk/fn`](../sdk/fn) package don't need any `//go:wasmimport` directives at all (the SDK uses stdin/stdout for I/O).
### Context
| Function | Description |
|----------|-------------|
| `get_caller_wallet()` → string | Wallet address of the caller (from JWT) |
| `get_caller_wallet()` → string | Resolved caller wallet (JWT subject if Bearer auth, else namespace pseudo-id when API-key auth). |
| `get_caller_jwt_subject()` → string | JWT `sub` claim explicitly. Empty when the request was not JWT-authenticated. Use this when binding on the JWT-signed identity matters (e.g. signup flows verifying the caller signed for the wallet they're registering). |
| `get_caller_claim(name)` → string | Custom JWT claim by name (tier, subscription, etc.). Empty if missing or non-JWT request. |
| `get_request_id()` → string | Unique invocation ID |
| `get_env(key)` → string | Environment variable from function.yaml |
| `get_secret(name)` → string | Decrypted secret value (see [Managing Secrets](#managing-secrets)) |
@ -116,15 +136,36 @@ All host functions are registered at runtime by the engine. They are available t
| Function | Description |
|----------|-------------|
| `db_query(sql, argsJSON)` → JSON | Execute SELECT query. Args as JSON array. Returns JSON array of row objects. |
| `db_execute(sql, argsJSON)` → int | Execute INSERT/UPDATE/DELETE. Returns affected row count. |
| `db_query_v2(sql, argsJSON)` → JSON | **Recommended.** Execute SELECT. Returns `{"rows": [...], "error": "..."}` — distinguishes empty result from query failure. |
| `db_execute_v2(sql, argsJSON)` → JSON | **Recommended.** Execute INSERT/UPDATE/DELETE. Returns `{"rows_affected": N, "last_insert_id": M, "error": "..."}` — distinguishes 0-rows-affected from a real failure. |
| `db_query(sql, argsJSON)` → JSON | Legacy. Execute SELECT, returns JSON array of rows. No way to surface query errors — prefer `db_query_v2`. |
| `db_execute(sql, argsJSON)` → int | Legacy. Returns affected rows ONLY. **Returns 0 for both "0 rows" and "SQL error" — caller can't distinguish.** Prefer `db_execute_v2`. |
| `db_transaction(opsJSON)` → JSON | Atomic batch — see "Database Transactions" below. |
Example query from WASM:
```
db_query("SELECT push_token, device_type FROM devices WHERE user_id = ?", '["user123"]')
→ [{"push_token": "abc...", "device_type": "ios"}]
Example v2 usage from WASM:
```go
//go:wasmimport env db_execute_v2
func dbExecuteV2(sqlPtr, sqlLen, argsPtr, argsLen uint32) uint64
resultBytes := callDBExecuteV2(`INSERT INTO event_seq (topic, next_seq) VALUES (?, 0)
ON CONFLICT(topic) DO NOTHING`,
[]any{"user/abc/account"})
var res struct {
RowsAffected int64 `json:"rows_affected"`
Error string `json:"error"`
}
json.Unmarshal(resultBytes, &res)
if res.Error != "" {
// Real failure — bail out, don't mark migration applied.
return fmt.Errorf("event_seq INSERT failed: %s", res.Error)
}
// res.RowsAffected may legitimately be 0 (ON CONFLICT DO NOTHING) — that's not an error.
```
The legacy `db_execute` is kept indefinitely so existing functions don't break. New code should use `db_execute_v2` for any path where distinguishing "no rows" from "SQL error" matters — most paths.
### Cache (Olric Distributed Cache)
| Function | Description |
@ -153,6 +194,49 @@ db_query("SELECT push_token, device_type FROM devices WHERE user_id = ?", '["use
| `log_info(message)` | Log info-level message (captured in invocation logs). |
| `log_error(message)` | Log error-level message. |
## Configuring Push Notifications (per-namespace)
Push providers (ntfy / Expo) are configured **per namespace** by the tenant —
no operator involvement, no SSH access required. Set, read, or clear via:
```bash
# Set / update (sensitive credentials are encrypted at rest)
curl -X PUT https://ns-myapp.example.com/v1/push/config \
-H 'Authorization: Bearer <user-jwt>' \
-H 'Content-Type: application/json' \
-d '{
"ntfy_base_url": "https://ntfy.sh",
"ntfy_auth_token": "tk_…"
}'
# Read (sensitive fields redacted to booleans)
curl https://ns-myapp.example.com/v1/push/config \
-H 'Authorization: Bearer <user-jwt>'
# Clear (push reverts to gateway-wide defaults if any, else 503)
curl -X DELETE https://ns-myapp.example.com/v1/push/config \
-H 'Authorization: Bearer <user-jwt>'
```
### Field semantics
| Field | Sensitive? | Notes |
|---|---|---|
| `ntfy_base_url` | No | URL of the ntfy server. `https://ntfy.sh` works for testing. |
| `ntfy_auth_token` | Yes | Optional bearer token sent to ntfy. Encrypted at rest. |
| `expo_access_token` | Yes | Expo Push API access token. Encrypted at rest. |
PUT semantics are **field-level** — a `null` (or omitted) field leaves the
existing value alone; an explicit empty string clears just that field. To
clear EVERYTHING use DELETE.
After a PUT the next `push_send` (host call) or `POST /v1/push/send` uses
the new providers — the cached dispatcher is invalidated automatically.
If no per-namespace config is set AND the gateway has no YAML defaults, the
push endpoints return **503 SERVICE_UNAVAILABLE** with a message naming the
exact config to set.
## Managing Secrets
Secrets are encrypted at rest (AES-256-GCM) and scoped to your namespace. Functions read them via `get_secret("name")` at runtime.

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

@ -0,0 +1,187 @@
# Stealth TURN Deployment Guide
## What this is
A TLS-level SNI router that lets Orama serve TURN-over-TLS on `:443`,
sharing the port with Caddy HTTPS. From a network observer's
perspective, TURN traffic is indistinguishable from ordinary HTTPS —
useful for users in regions that block standard VoIP ports (UAE, Saudi
Arabia, China, Iran).
## Architecture
```
Internet
TCP :443
┌─────────┴─────────┐
│ orama-sni-router │ peeks SNI, forwards bytes
└─────────┬─────────┘
┌───────────────┼────────────────┐
▼ ▼
cdn.<base> *.<base>, <base>
turn.<base> (everything else)
│ │
▼ ▼
Pion TURN-TLS Caddy
127.0.0.1:5349 127.0.0.1:8443
(existing) (moved from :443)
```
The router does **not** terminate TLS. It reads the unencrypted TLS
ClientHello (first ~5 KB), inspects the SNI extension, and dials the
matching backend. Encrypted bytes pass through verbatim.
## Components
- **Library:** `pkg/sniproxy/` — ClientHello parser, route table, TCP server
- **Binary:** `cmd/sni-router/` (built as `bin/orama-sni-router`)
- **Systemd unit:** `systemd/orama-sni-router.service`
- **Config:** `~/.orama/sni-router.yaml`
## Deployment cutover
⚠️ **This change touches production `:443`. Stage on one node first, watch for 24h, then roll out.**
### 1. Reconfigure Caddy to listen on `:8443`
Update wherever the Caddy config is generated (`pkg/environments/production/installers/caddy.go`)
so Caddy binds `:8443` (HTTPS) and `:8080` (HTTP) instead of `:443` and `:80`.
Drop `CAP_NET_BIND_SERVICE` from Caddy's systemd unit — it no longer needs privileged ports.
### 2. Provision the cert SAN for `cdn.<base-domain>`
Caddy's automatic Let's Encrypt flow needs to issue a cert covering
`cdn.<base-domain>` and `cdn.ns-*.<base-domain>` so Pion TURN can read it
on startup. Add these names to Caddy's TLS config block.
### 3. Drop `sni-router.yaml` config
Example for a single-namespace node:
```yaml
listen: ":443"
client_hello_timeout: 5s
backend_dial_timeout: 5s
max_concurrent_conns: 10000
fallback:
name: caddy
addr: "127.0.0.1:8443"
routes:
- match: "cdn.example.com"
backend:
name: turn-tls
addr: "127.0.0.1:5349"
- match: "turn.example.com"
backend:
name: turn-tls
addr: "127.0.0.1:5349"
```
For multi-namespace, add per-namespace TURN backends (each namespace's
TURN-TLS port is allocated by `pkg/namespace`):
```yaml
- match: "cdn.ns-myapp.example.com"
backend: { name: "turn-myapp", addr: "127.0.0.1:5349" }
- match: "cdn.ns-other.example.com"
backend: { name: "turn-other", addr: "127.0.0.1:5350" }
```
### 4. Deploy + start in order
```bash
# Install binary
sudo cp bin-linux/orama-sni-router /opt/orama/bin/
# Install service
sudo cp systemd/orama-sni-router.service /etc/systemd/system/
sudo systemctl daemon-reload
# Stop Caddy briefly (it's about to lose :443)
sudo systemctl stop caddy
# Start the SNI router (it takes :443)
sudo systemctl enable --now orama-sni-router
# Restart Caddy on its new port
sudo systemctl start caddy
# Verify
curl -v https://cdn.<base>:443 # should hit TURN backend (TLS handshake will fail; that's fine)
curl -v https://<base>:443 # should hit Caddy (normal HTTPS response)
```
### 5. Enable stealth in the gateway
Once the SNI router is live, tell the gateway to advertise the stealth URI:
```go
// in gateway dependencies / startup
webrtcHandlers.SetStealthCDNDomain("cdn.<base-domain>")
```
The credentials handler will start including `turns:cdn.<base-domain>:443`
in `POST /v1/webrtc/turn/credentials` responses automatically.
### 6. Monitor
```bash
journalctl -u orama-sni-router.service -f
journalctl -u caddy.service -f
```
Watch for:
- `Connection limit reached` warnings (bump `max_concurrent_conns`)
- `backend dial failed` warnings (Caddy isn't listening on `:8443`, or TURN isn't on `:5349`)
- `ClientHello peek failed` debugs (curious clients sending non-TLS to `:443` — usually port scanners)
## Rollback
If anything is wrong:
```bash
sudo systemctl stop orama-sni-router
# Reconfigure Caddy back to :443 and restart
sudo systemctl restart caddy
```
Caddy reclaiming `:443` from the disabled router is the fastest way back to
the previous topology.
## Known gaps
- **Dynamic route source:** today's router reads YAML once at startup. To
pick up new namespaces without restart, implement a `RouteSource` that
polls `pkg/namespace` for active TURN deployments. The library is
already designed for `Router.Replace` to be called concurrently.
- **TLS cert hot-reload:** Pion TURN reads the cert once at startup. When
Caddy renews `cdn.<base-domain>`, Pion needs to be restarted to pick up
the new cert. A small file-watcher service (or a periodic restart in
off-peak hours) handles this for now.
## What clients see
Once enabled, the credentials response gains one entry:
```json
{
"username": "...",
"password": "...",
"ttl": 600,
"uris": [
"turn:turn.example.com:3478?transport=udp",
"turn:turn.example.com:3478?transport=tcp",
"turns:turn.example.com:5349",
"turns:cdn.example.com:443"
]
}
```
Browsers iterate ICE candidates; users in restricted regions will silently
succeed via the `:443` URI when others fail. No client-side change is
required.

View File

@ -0,0 +1,14 @@
-- Add operator wallet tracking to nodes.
-- operator_wallet links nodes to the wallet that provisioned them.
ALTER TABLE dns_nodes ADD COLUMN operator_wallet TEXT;
ALTER TABLE dns_nodes ADD COLUMN environment TEXT DEFAULT 'production';
ALTER TABLE dns_nodes ADD COLUMN ssh_user TEXT DEFAULT 'root';
ALTER TABLE dns_nodes ADD COLUMN role TEXT DEFAULT 'node';
CREATE INDEX IF NOT EXISTS idx_dns_nodes_operator ON dns_nodes(operator_wallet);
CREATE INDEX IF NOT EXISTS idx_dns_nodes_environment ON dns_nodes(environment);
ALTER TABLE wireguard_peers ADD COLUMN operator_wallet TEXT;
ALTER TABLE invite_tokens ADD COLUMN operator_wallet TEXT;

View File

@ -0,0 +1,28 @@
-- =============================================================================
-- 021_pubsub_trigger_patterns.sql
--
-- Add `topic_pattern` column alongside the existing `topic` column to
-- function_pubsub_triggers. The new column may contain SQLite GLOB
-- patterns (e.g. "presence:*") in addition to exact topic names.
--
-- This is intentionally ADDITIVE rather than a column rename to remain
-- safe under rolling upgrades:
-- - Old binaries continue reading `topic` and keep working.
-- - New binaries read `topic_pattern` (which is back-filled from
-- `topic` for existing rows) and write BOTH columns.
-- A future migration can DROP COLUMN topic once every node is on the
-- new release.
-- =============================================================================
ALTER TABLE function_pubsub_triggers
ADD COLUMN topic_pattern TEXT NOT NULL DEFAULT '';
UPDATE function_pubsub_triggers
SET topic_pattern = topic
WHERE topic_pattern = '';
CREATE INDEX IF NOT EXISTS idx_function_pubsub_triggers_function
ON function_pubsub_triggers(function_id);
CREATE INDEX IF NOT EXISTS idx_function_pubsub_triggers_enabled
ON function_pubsub_triggers(enabled);

View File

@ -0,0 +1,20 @@
-- =============================================================================
-- 022_aggregation_windows.sql
--
-- Add per-trigger aggregation parameters to function_pubsub_triggers.
--
-- aggregation_window_ms = 0 means "no aggregation, invoke once per event"
-- (the existing behaviour). Any positive value enables buffering of events
-- in-memory on the dispatching node; the function is invoked once per
-- window with a batched payload.
--
-- aggregation_max_batch_size caps the per-window batch. When the buffer
-- reaches this size, the dispatcher flushes immediately even if the
-- window timer hasn't fired yet.
-- =============================================================================
ALTER TABLE function_pubsub_triggers
ADD COLUMN aggregation_window_ms INTEGER NOT NULL DEFAULT 0;
ALTER TABLE function_pubsub_triggers
ADD COLUMN aggregation_max_batch_size INTEGER NOT NULL DEFAULT 100;

View File

@ -0,0 +1,33 @@
-- =============================================================================
-- 023_push_devices.sql
--
-- Per-namespace, per-user push notification device registry.
--
-- token_encrypted is AES-256-GCM ciphertext (prefix 'enc:') derived via
-- pkg/secrets. Tokens are sensitive — they let the holder spam a user's
-- device — so they are never returned via any API or written to logs.
--
-- provider matches a registered push.PushProvider name:
-- 'ntfy', 'expo', 'apns', 'fcm' (future), ...
-- =============================================================================
CREATE TABLE IF NOT EXISTS push_devices (
id TEXT PRIMARY KEY,
namespace TEXT NOT NULL,
user_id TEXT NOT NULL,
device_id TEXT NOT NULL,
provider TEXT NOT NULL,
token_encrypted TEXT NOT NULL,
platform TEXT,
app_version TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_seen INTEGER,
UNIQUE(namespace, user_id, device_id)
);
CREATE INDEX IF NOT EXISTS idx_push_devices_user
ON push_devices(namespace, user_id);
CREATE INDEX IF NOT EXISTS idx_push_devices_provider
ON push_devices(provider);

View File

@ -0,0 +1,18 @@
-- =============================================================================
-- 024_namespace_publish_seq.sql
--
-- Per-namespace monotonically-increasing sequence number assigned by
-- exec_and_publish (plan 08). The seq is included in the wake-up payload so
-- subscribers can detect "I'm behind, retry" gaps caused by cross-node
-- replication lag between the leader's commit and the gossipsub message.
--
-- The row is upserted in the same atomic batch as the user's writes, so the
-- assigned seq exactly mirrors the commit number. See plan:
-- core/plans/platform/08_EXEC_AND_PUBLISH.md
-- =============================================================================
CREATE TABLE IF NOT EXISTS namespace_publish_seq (
namespace TEXT PRIMARY KEY,
next_seq BIGINT NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL
);

View File

@ -0,0 +1,18 @@
-- =============================================================================
-- 025_persistent_ws.sql
--
-- Persistent WebSocket function settings — see plan
-- core/plans/platform/06_PERSISTENT_WS_FUNCTIONS.md
--
-- When ws_persistent is true, the function is bound to a single WebSocket
-- connection for its lifetime; exports ws_open / ws_frame / ws_close instead
-- of the default _start. See pkg/serverless/persistent for runtime details.
--
-- All defaults are zero / false → backward compatible: existing functions
-- continue to use the per-frame stateless WS model.
-- =============================================================================
ALTER TABLE functions ADD COLUMN ws_persistent BOOLEAN DEFAULT FALSE;
ALTER TABLE functions ADD COLUMN ws_idle_timeout_sec INTEGER DEFAULT 0;
ALTER TABLE functions ADD COLUMN ws_max_frame_bytes INTEGER DEFAULT 0;
ALTER TABLE functions ADD COLUMN ws_max_inflight_per_conn INTEGER DEFAULT 0;

View File

@ -0,0 +1,26 @@
-- =============================================================================
-- 026_namespace_push_config.sql
--
-- Per-namespace push notification provider configuration. Tenants set their
-- own ntfy / expo credentials via PUT /v1/push/config without operator
-- involvement (bug #220 follow-up — self-service tenant config).
--
-- Sensitive credentials (auth tokens) are AES-256-GCM ciphertext via
-- pkg/secrets, prefix 'enc:'. Non-secret URLs (ntfy_base_url) stored
-- plaintext — they leak no security material.
--
-- The gateway YAML config remains as a global fallback / default. A row
-- in this table OVERRIDES the YAML for that namespace; absence falls back.
-- =============================================================================
CREATE TABLE IF NOT EXISTS namespace_push_config (
namespace TEXT PRIMARY KEY,
-- ntfy provider config (URL is non-secret; auth token is)
ntfy_base_url TEXT,
ntfy_auth_token_encrypted TEXT,
-- expo provider config (the access token IS sensitive)
expo_access_token_encrypted TEXT,
-- Audit metadata: who set this, and when (last update wins).
updated_at INTEGER NOT NULL,
updated_by TEXT
);

194
core/migrations/contract.go Normal file
View File

@ -0,0 +1,194 @@
// Package migrations holds the embedded SQL migrations for the gateway's
// RQLite registry. This file defines the schema-version contract every
// gateway binary must enforce at startup.
//
// The contract:
//
// 1. The binary embeds every migration file in this directory.
// 2. RequiredVersion() returns the highest numbered migration in the embed.
// This is the schema version the binary REQUIRES to function correctly.
// 3. AssertSchema(ctx, db) queries the schema_migrations table and returns
// a typed *SchemaMismatchError if the applied version is below
// RequiredVersion. Gateway startup MUST treat this as fatal.
//
// Why: a rolling upgrade can swap the gateway binary without restarting the
// underlying RQLite process. If a new binary expects columns added by a
// migration the RQLite-process startup never re-ran, INSERTs fail with
// cryptic errors at runtime. Asserting the contract at startup catches the
// mismatch immediately with an actionable error message.
//
// See plan: this file is the long-term fix for the AnChat-test "missing
// ws_max_frame_bytes column" incident (2026-05-06).
package migrations
import (
"context"
"database/sql"
"fmt"
"io/fs"
"sort"
"strconv"
"strings"
)
// MigrationInfo describes one embedded migration.
type MigrationInfo struct {
Version int
Name string
Path string
}
// allMigrations returns every embedded migration sorted by version ascending.
// Computed once at startup; cheap to call repeatedly.
var allMigrations = mustListMigrations()
func mustListMigrations() []MigrationInfo {
entries, err := fs.ReadDir(FS, ".")
if err != nil {
// In practice this can't happen — the embed.FS is built from a
// known directory. If it does, we can't safely run anything.
panic(fmt.Sprintf("migrations: failed to list embedded files: %v", err))
}
var out []MigrationInfo
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(name, ".sql") {
continue
}
v, ok := parseVersion(name)
if !ok {
continue
}
out = append(out, MigrationInfo{
Version: v,
Name: strings.TrimSuffix(name, ".sql"),
Path: name,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version })
return out
}
// parseVersion extracts the integer prefix from "001_initial.sql" → 1.
// Returns ok=false for files without a leading numeric prefix.
func parseVersion(filename string) (int, bool) {
idx := strings.IndexByte(filename, '_')
if idx <= 0 {
return 0, false
}
v, err := strconv.Atoi(filename[:idx])
if err != nil {
return 0, false
}
return v, true
}
// All returns a snapshot of every embedded migration, sorted by version.
// The returned slice is a copy; safe to mutate.
func All() []MigrationInfo {
out := make([]MigrationInfo, len(allMigrations))
copy(out, allMigrations)
return out
}
// RequiredVersion returns the highest migration version embedded in this
// binary. Panics if no migrations are embedded (impossible in practice).
//
// This is the schema version the binary requires. The gateway asserts at
// startup that the database's applied schema is >= this value.
func RequiredVersion() int {
if len(allMigrations) == 0 {
panic("migrations: no embedded migrations found")
}
return allMigrations[len(allMigrations)-1].Version
}
// SchemaMismatchError is returned when the database's applied schema is
// behind what the binary requires. Gateway startup MUST treat this as fatal
// and log the actionable hint.
type SchemaMismatchError struct {
RequiredVersion int
AppliedVersion int
Pending []MigrationInfo // migrations the binary has but the DB lacks
}
func (e *SchemaMismatchError) Error() string {
pending := make([]string, 0, len(e.Pending))
for _, m := range e.Pending {
pending = append(pending, fmt.Sprintf("%03d (%s)", m.Version, m.Name))
}
return fmt.Sprintf(
"schema mismatch: binary requires version %d, database has %d. "+
"Pending migrations: [%s]. "+
"Run `orama node migrate-apply` on the namespace's RQLite to fix.",
e.RequiredVersion, e.AppliedVersion, strings.Join(pending, ", "),
)
}
// AppliedVersion queries the schema_migrations table and returns the highest
// version recorded as applied. Returns 0 (with nil error) if the table is
// empty — that's a fresh database, valid state.
//
// Returns an error if the schema_migrations table itself doesn't exist or
// can't be read; callers must distinguish that from "applied=0".
func AppliedVersion(ctx context.Context, db *sql.DB) (int, error) {
row := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version), 0) FROM schema_migrations`)
var v int
if err := row.Scan(&v); err != nil {
return 0, fmt.Errorf("migrations: query schema_migrations: %w", err)
}
return v, nil
}
// AssertSchema verifies the database's applied schema is at least
// RequiredVersion(). Returns nil on match-or-newer, *SchemaMismatchError
// on lag.
//
// Newer-than-required is OK — that means an older binary is talking to a
// database that's been advanced by a newer binary in the cluster. The
// binary just won't use whatever the newer columns enable. (Gateway
// startup should still allow this; it's a normal rolling-upgrade window.)
func AssertSchema(ctx context.Context, db *sql.DB) error {
required := RequiredVersion()
applied, err := AppliedVersion(ctx, db)
if err != nil {
return fmt.Errorf("migrations.AssertSchema: %w", err)
}
if applied >= required {
return nil
}
// Compute pending migrations for the error message.
pending := make([]MigrationInfo, 0)
for _, m := range allMigrations {
if m.Version > applied {
pending = append(pending, m)
}
}
return &SchemaMismatchError{
RequiredVersion: required,
AppliedVersion: applied,
Pending: pending,
}
}
// PendingMigrations returns migrations the binary has but the database
// hasn't applied. Used by the `orama node migrate-status` CLI to show
// the operator what would be applied by a `migrate-apply`.
func PendingMigrations(ctx context.Context, db *sql.DB) ([]MigrationInfo, error) {
applied, err := AppliedVersion(ctx, db)
if err != nil {
return nil, err
}
out := make([]MigrationInfo, 0)
for _, m := range allMigrations {
if m.Version > applied {
out = append(out, m)
}
}
return out, nil
}

View File

@ -0,0 +1,231 @@
package migrations
import (
"context"
"database/sql"
"errors"
"strings"
"testing"
_ "github.com/mattn/go-sqlite3"
)
// openTestDB returns an in-memory SQLite database. The migrations contract
// only cares about ANSI-ish SQL (CREATE TABLE, SELECT MAX, INSERT) — we
// don't need RQLite's distributed semantics for these tests.
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open in-memory sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func ensureMigrationsTable(t *testing.T, db *sql.DB) {
t.Helper()
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`)
if err != nil {
t.Fatalf("create schema_migrations: %v", err)
}
}
func TestRequiredVersion_matches_highest_embedded(t *testing.T) {
all := All()
if len(all) == 0 {
t.Fatal("no embedded migrations — embed.FS broken?")
}
want := all[len(all)-1].Version
if got := RequiredVersion(); got != want {
t.Errorf("RequiredVersion() = %d, want %d", got, want)
}
}
func TestAll_returns_sorted_copy(t *testing.T) {
a := All()
for i := 1; i < len(a); i++ {
if a[i-1].Version >= a[i].Version {
t.Errorf("All() not sorted: %d before %d", a[i-1].Version, a[i].Version)
}
}
// Mutating the returned slice must not affect subsequent calls.
if len(a) > 0 {
a[0].Version = -999
}
a2 := All()
if len(a2) > 0 && a2[0].Version == -999 {
t.Error("All() returned a shared slice — subsequent calls see mutation")
}
}
func TestAppliedVersion_empty_returns_zero(t *testing.T) {
db := openTestDB(t)
ensureMigrationsTable(t, db)
v, err := AppliedVersion(context.Background(), db)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if v != 0 {
t.Errorf("expected 0 for empty schema_migrations, got %d", v)
}
}
func TestAppliedVersion_returns_max(t *testing.T) {
db := openTestDB(t)
ensureMigrationsTable(t, db)
for _, v := range []int{1, 5, 3, 10, 7} {
_, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", v)
if err != nil {
t.Fatalf("insert %d: %v", v, err)
}
}
v, err := AppliedVersion(context.Background(), db)
if err != nil {
t.Fatalf("AppliedVersion: %v", err)
}
if v != 10 {
t.Errorf("expected 10, got %d", v)
}
}
func TestAppliedVersion_no_table_returns_error(t *testing.T) {
db := openTestDB(t)
// Don't create schema_migrations table.
_, err := AppliedVersion(context.Background(), db)
if err == nil {
t.Fatal("expected error when schema_migrations missing")
}
}
func TestAssertSchema_ok_when_at_required(t *testing.T) {
db := openTestDB(t)
ensureMigrationsTable(t, db)
_, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", RequiredVersion())
if err != nil {
t.Fatalf("seed: %v", err)
}
if err := AssertSchema(context.Background(), db); err != nil {
t.Errorf("AssertSchema returned error when at required version: %v", err)
}
}
func TestAssertSchema_ok_when_above_required(t *testing.T) {
db := openTestDB(t)
ensureMigrationsTable(t, db)
_, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", RequiredVersion()+10)
if err != nil {
t.Fatalf("seed: %v", err)
}
if err := AssertSchema(context.Background(), db); err != nil {
t.Errorf("AssertSchema returned error when ahead of required: %v", err)
}
}
func TestAssertSchema_fails_when_below_required(t *testing.T) {
db := openTestDB(t)
ensureMigrationsTable(t, db)
// Seed only the first migration.
_, err := db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", 1)
if err != nil {
t.Fatalf("seed: %v", err)
}
err = AssertSchema(context.Background(), db)
if err == nil {
t.Fatal("expected SchemaMismatchError, got nil")
}
var smErr *SchemaMismatchError
if !errors.As(err, &smErr) {
t.Fatalf("expected *SchemaMismatchError, got %T: %v", err, err)
}
if smErr.RequiredVersion != RequiredVersion() {
t.Errorf("RequiredVersion mismatch: got %d, want %d", smErr.RequiredVersion, RequiredVersion())
}
if smErr.AppliedVersion != 1 {
t.Errorf("AppliedVersion mismatch: got %d, want 1", smErr.AppliedVersion)
}
if len(smErr.Pending) == 0 {
t.Error("expected pending migrations list, got empty")
}
// Error message must contain the actionable hint.
if !strings.Contains(err.Error(), "orama node migrate") {
t.Errorf("error message lacks actionable hint: %v", err)
}
}
func TestPendingMigrations_empty_when_at_required(t *testing.T) {
db := openTestDB(t)
ensureMigrationsTable(t, db)
_, _ = db.Exec("INSERT INTO schema_migrations (version) VALUES (?)", RequiredVersion())
pending, err := PendingMigrations(context.Background(), db)
if err != nil {
t.Fatalf("PendingMigrations: %v", err)
}
if len(pending) != 0 {
t.Errorf("expected 0 pending, got %d", len(pending))
}
}
func TestPendingMigrations_lists_all_when_empty_db(t *testing.T) {
db := openTestDB(t)
ensureMigrationsTable(t, db)
pending, err := PendingMigrations(context.Background(), db)
if err != nil {
t.Fatalf("PendingMigrations: %v", err)
}
if len(pending) != len(All()) {
t.Errorf("expected %d pending (all), got %d", len(All()), len(pending))
}
}
func TestParseVersion(t *testing.T) {
cases := []struct {
name string
in string
want int
ok bool
}{
{"valid 3-digit", "001_initial.sql", 1, true},
{"valid 25", "025_persistent_ws.sql", 25, true},
{"valid 100", "100_future.sql", 100, true},
{"no underscore", "999.sql", 0, false},
{"non-numeric prefix", "abc_initial.sql", 0, false},
{"empty", "", 0, false},
{"only underscore", "_x.sql", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, ok := parseVersion(c.in)
if ok != c.ok || got != c.want {
t.Errorf("parseVersion(%q) = (%d, %v), want (%d, %v)",
c.in, got, ok, c.want, c.ok)
}
})
}
}
func TestSchemaMismatchError_message_lists_pending(t *testing.T) {
e := &SchemaMismatchError{
RequiredVersion: 25,
AppliedVersion: 22,
Pending: []MigrationInfo{
{Version: 23, Name: "push_devices"},
{Version: 24, Name: "namespace_publish_seq"},
{Version: 25, Name: "persistent_ws"},
},
}
msg := e.Error()
for _, want := range []string{"025", "024", "023", "push_devices", "namespace_publish_seq", "persistent_ws", "orama node migrate"} {
if !strings.Contains(msg, want) {
t.Errorf("error message missing %q: %s", want, msg)
}
}
}

View File

@ -0,0 +1,237 @@
package migrations_test
// roundtrip_test.go is the build-time guard that prevents
// "binary references column X but X is missing from migrations"
// drift — the bug that triggered the AnChat-test outage on 2026-05-06.
//
// How it works:
//
// 1. Open an in-memory SQLite database.
// 2. Apply EVERY embedded migration in version order.
// 3. Run a series of "exemplar" SQL operations against the resulting
// schema. If any operation fails, the test fails — meaning either:
// a. A migration was deleted / renumbered and the schema regressed
// b. A new migration was added but isn't reachable via embed.FS
// c. (Most importantly) a Go file references a column / table /
// index that no migration creates
//
// The exemplars are drawn from the actual SQL strings the platform's
// Go code executes. Adding a new INSERT/SELECT in the gateway → add the
// matching exemplar here so drift is caught at `go test` time, not
// at production deploy.
//
// This is generic by design — every platform table participates. Adding
// a new table doesn't require new test infrastructure, only one new
// exemplar string.
import (
"database/sql"
"strings"
"testing"
"github.com/DeBrosOfficial/network/migrations"
"github.com/DeBrosOfficial/network/pkg/rqlite"
_ "github.com/mattn/go-sqlite3"
"go.uber.org/zap"
)
// TestSchemaRoundtrip_AllMigrationsApplyClean verifies every embedded
// migration applies successfully against a fresh database in version
// order. Failure here means a migration is broken in isolation
// (syntax error, references a missing prior migration's column, etc.).
func TestSchemaRoundtrip_AllMigrationsApplyClean(t *testing.T) {
db := openRoundtripDB(t)
if err := rqlite.ApplyEmbeddedMigrations(t.Context(), db, migrations.FS, zap.NewNop()); err != nil {
t.Fatalf("ApplyEmbeddedMigrations failed: %v", err)
}
// Sanity: applied version should equal RequiredVersion.
applied, err := migrations.AppliedVersion(t.Context(), db)
if err != nil {
t.Fatalf("AppliedVersion: %v", err)
}
if applied != migrations.RequiredVersion() {
t.Errorf("applied=%d != required=%d after full roundtrip", applied, migrations.RequiredVersion())
}
}
// TestSchemaRoundtrip_PlatformExemplars exercises representative SQL
// statements from the Go codebase against the migrated schema.
//
// Each exemplar is a string that should EXECUTE successfully (we don't
// care about row counts — only that the SQL parses and binds against
// the schema). Args are placeholders; values can be anything matching
// the column types.
//
// When a Go handler is added that touches a new table or column, add
// an exemplar here. The diff at review time enforces the contract:
// "if you write Go that uses column X, an exemplar exercises it,
// which means migrations must declare X."
func TestSchemaRoundtrip_PlatformExemplars(t *testing.T) {
db := openRoundtripDB(t)
if err := rqlite.ApplyEmbeddedMigrations(t.Context(), db, migrations.FS, zap.NewNop()); err != nil {
t.Fatalf("ApplyEmbeddedMigrations: %v", err)
}
// Each exemplar is (table, sql, args). The args don't have to satisfy
// constraints — we use Prepare to validate column references without
// actually running mutations. Statements that have to execute (because
// SQLite delays some checks) get marked exec=true.
type exemplar struct {
name string
sql string
args []any
exec bool // true: actually execute; false: just Prepare
}
exemplars := []exemplar{
// functions table — bug #214's table, which is why we care.
// Every column written by the function-store INSERT must be here.
{
name: "functions INSERT (full column list incl. ws_*)",
sql: `INSERT INTO functions (
id, name, namespace, version, wasm_cid,
memory_limit_mb, timeout_seconds, is_public,
retry_count, retry_delay_seconds, dlq_topic,
status, created_at, updated_at, created_by,
ws_persistent, ws_idle_timeout_sec, ws_max_frame_bytes, ws_max_inflight_per_conn
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: []any{
"id-1", "fn", "ns", 1, "cid-1",
64, 30, false,
0, 5, "",
"active", 0, 0, "ns",
false, 0, 0, 0,
},
exec: true,
},
{
name: "functions SELECT (full column list)",
sql: `SELECT id, name, namespace, version, wasm_cid, source_cid,
ws_persistent, ws_idle_timeout_sec, ws_max_frame_bytes, ws_max_inflight_per_conn,
memory_limit_mb, timeout_seconds, is_public,
retry_count, retry_delay_seconds, dlq_topic,
status, created_at, updated_at, created_by
FROM functions WHERE namespace = ? AND name = ?`,
args: []any{"ns", "fn"},
},
// function_invocations — used by the invocation-history view (#211 fix).
{
name: "function_invocations INSERT",
sql: `INSERT INTO function_invocations (
id, function_id, request_id, trigger_type, caller_wallet,
input_size, output_size, started_at, completed_at,
duration_ms, status, error_message, memory_used_mb
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: []any{
"inv-1", "id-1", "req-A", "http", "0xwallet",
0, 0, 0, 0,
0, "success", "", 0.0,
},
exec: true,
},
{
name: "function_invocations SELECT for GetInvocations",
sql: `SELECT i.id, i.request_id, i.trigger_type, i.caller_wallet,
i.input_size, i.output_size, i.started_at, i.completed_at,
i.duration_ms, i.status, i.error_message, i.memory_used_mb
FROM function_invocations i
JOIN functions f ON i.function_id = f.id
WHERE f.namespace = ? AND f.name = ?
ORDER BY i.started_at DESC LIMIT ?`,
args: []any{"ns", "fn", 50},
},
// function_logs — WASM-emitted log lines.
{
name: "function_logs INSERT",
sql: `INSERT INTO function_logs (
id, function_id, invocation_id, level, message, timestamp
) VALUES (?, ?, ?, ?, ?, ?)`,
args: []any{"log-1", "id-1", "inv-1", "info", "hi", 0},
exec: true,
},
// function_pubsub_triggers — wildcard trigger column rename (plan 03).
// During the dual-column rolling-upgrade window the Go code writes
// BOTH `topic` (legacy NOT NULL) and `topic_pattern` (new); this
// exemplar mirrors the actual INSERT and would catch a future
// migration that drops one column without a corresponding code change.
{
name: "function_pubsub_triggers INSERT (dual topic+topic_pattern)",
sql: `INSERT INTO function_pubsub_triggers (
id, function_id, topic, topic_pattern,
enabled, created_at,
aggregation_window_ms, aggregation_max_batch_size
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
args: []any{"trig-1", "id-1", "presence:*", "presence:*", true, 0, 0, 0},
exec: true,
},
// push_devices — created by migration 023; encrypted token storage.
{
name: "push_devices INSERT",
sql: `INSERT INTO push_devices (
id, namespace, user_id, device_id, provider,
token_encrypted, platform, app_version,
created_at, updated_at, last_seen
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: []any{
"dev-1", "ns", "u1", "device-A", "ntfy",
"enc:...", "ios", "1.0",
0, 0, 0,
},
exec: true,
},
// namespace_publish_seq — sequence counter from plan 08.
{
name: "namespace_publish_seq UPSERT",
sql: `INSERT INTO namespace_publish_seq (namespace, next_seq, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(namespace) DO UPDATE SET
next_seq = next_seq + 1,
updated_at = excluded.updated_at`,
args: []any{"ns", 2, 0},
exec: true,
},
}
for _, ex := range exemplars {
t.Run(ex.name, func(t *testing.T) {
if ex.exec {
if _, err := db.Exec(ex.sql, ex.args...); err != nil {
t.Errorf("schema drift: %v\nsql: %s", err, snippet(ex.sql))
}
return
}
stmt, err := db.Prepare(ex.sql)
if err != nil {
t.Errorf("schema drift (Prepare failed): %v\nsql: %s", err, snippet(ex.sql))
return
}
defer func() { _ = stmt.Close() }()
})
}
}
// openRoundtripDB returns an in-memory SQLite. Closes automatically on
// test cleanup.
func openRoundtripDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open in-memory sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
// snippet trims a SQL string to fit on a single error line.
func snippet(s string) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > 140 {
return s[:140] + "..."
}
return s
}

View File

@ -3,54 +3,58 @@ package auth
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"time"
"github.com/DeBrosOfficial/network/pkg/rwagent"
"github.com/DeBrosOfficial/network/pkg/tlsutil"
)
// IsRootWalletInstalled checks if the `rw` CLI is available in PATH
// IsRootWalletInstalled checks if the rootwallet agent is reachable.
func IsRootWalletInstalled() bool {
_, err := exec.LookPath("rw")
return err == nil
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return client.IsRunning(ctx)
}
// getRootWalletAddress gets the EVM address from the RootWallet keystore
// getRootWalletAddress gets the EVM address from the rootwallet agent.
func getRootWalletAddress() (string, error) {
cmd := exec.Command("rw", "address", "--chain", "evm")
cmd.Stderr = os.Stderr
out, err := cmd.Output()
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
data, err := client.GetAddress(ctx, "evm")
if err != nil {
return "", fmt.Errorf("failed to get address from rw: %w", err)
return "", fmt.Errorf("failed to get address from rootwallet agent: %w", err)
}
addr := strings.TrimSpace(string(out))
if addr == "" {
return "", fmt.Errorf("rw returned empty address — run 'rw init' first")
if data.Address == "" {
return "", fmt.Errorf("rootwallet agent returned empty address")
}
return addr, nil
return data.Address, nil
}
// signWithRootWallet signs a message using RootWallet's EVM key.
// Stdin is passed through so the user can enter their password if the session is expired.
// signWithRootWallet signs a message using the rootwallet agent's EVM key.
// The desktop app may prompt the user for approval.
func signWithRootWallet(message string) (string, error) {
cmd := exec.Command("rw", "sign", message, "--chain", "evm")
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
out, err := cmd.Output()
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
data, err := client.Sign(ctx, message, "evm")
if err != nil {
return "", fmt.Errorf("failed to sign with rw: %w", err)
return "", fmt.Errorf("failed to sign with rootwallet agent: %w", err)
}
sig := strings.TrimSpace(string(out))
if sig == "" {
return "", fmt.Errorf("rw returned empty signature")
if data.Signature == "" {
return "", fmt.Errorf("rootwallet agent returned empty signature")
}
return sig, nil
return data.Signature, nil
}
// PerformRootWalletAuthentication performs a challenge-response authentication flow

View File

@ -157,6 +157,7 @@ func (b *Builder) buildOramaBinaries() error {
{Name: "identity", Package: "./cmd/identity/"},
{Name: "sfu", Package: "./cmd/sfu/"},
{Name: "turn", Package: "./cmd/turn/"},
{Name: "orama-sni-router", Package: "./cmd/sni-router/"},
}
for _, bin := range binaries {
@ -197,8 +198,8 @@ func (b *Builder) buildVaultGuardian() error {
return fmt.Errorf("zig not found in PATH — install from https://ziglang.org/download/")
}
// Vault source is sibling to orama project
vaultDir := filepath.Join(b.projectDir, "..", "orama-vault")
// Vault source is sibling to core/ within the orama monorepo
vaultDir := filepath.Join(b.projectDir, "..", "vault")
if _, err := os.Stat(filepath.Join(vaultDir, "build.zig")); err != nil {
return fmt.Errorf("vault source not found at %s — expected orama-vault as sibling directory: %w", vaultDir, err)
}

View File

@ -0,0 +1,116 @@
package node
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/DeBrosOfficial/network/pkg/auth"
"github.com/DeBrosOfficial/network/pkg/cli"
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
"github.com/spf13/cobra"
)
var migrateConfEnv string
var migrateConfCmd = &cobra.Command{
Use: "migrate-conf",
Short: "Register nodes.conf nodes with your wallet",
Long: `One-time migration: reads nodes from nodes.conf for an environment
and registers each with your wallet via the gateway API. After migration,
these nodes will appear in 'orama nodes' output.
Requires: orama auth login (for API authentication)`,
RunE: func(cmd *cobra.Command, args []string) error {
env := migrateConfEnv
if env == "" {
active, err := cli.GetActiveEnvironment()
if err != nil {
return fmt.Errorf("failed to get active environment: %w", err)
}
env = active.Name
}
// Load nodes from nodes.conf
nodes, err := remotessh.LoadEnvNodes(env)
if err != nil {
return fmt.Errorf("failed to load nodes.conf: %w", err)
}
// Get gateway URL
envConfig, err := cli.GetEnvironmentByName(env)
if err != nil {
return fmt.Errorf("environment %q not configured: %w", env, err)
}
// Load stored credentials
store, err := auth.LoadEnhancedCredentials()
if err != nil {
return fmt.Errorf("failed to load credentials: %w", err)
}
creds := store.GetDefaultCredential(envConfig.GatewayURL)
if creds == nil || creds.APIKey == "" {
return fmt.Errorf("no credentials for %s — run 'orama auth login' first", envConfig.GatewayURL)
}
if len(nodes) == 0 {
fmt.Printf("No nodes found for environment %q in nodes.conf\n", env)
return nil
}
fmt.Printf("Migrating %d node(s) from nodes.conf to %s...\n\n", len(nodes), env)
httpClient := &http.Client{Timeout: 10 * time.Second}
registered := 0
for _, n := range nodes {
body := map[string]string{
"ip_address": n.Host,
"environment": env,
"role": n.Role,
"ssh_user": n.User,
}
payload, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost,
envConfig.GatewayURL+"/v1/operator/node/register",
bytes.NewReader(payload))
if err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), " %s: failed to create request: %v\n", n.Host, err)
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", creds.APIKey)
resp, err := httpClient.Do(req)
if err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), " %s: request failed: %v\n", n.Host, err)
continue
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
fmt.Printf(" %s (%s): registered\n", n.Host, n.Role)
registered++
} else if resp.StatusCode == http.StatusNotFound {
fmt.Printf(" %s: not found in cluster (node may not have joined yet)\n", n.Host)
} else {
fmt.Fprintf(cmd.ErrOrStderr(), " %s: HTTP %d: %s\n", n.Host, resp.StatusCode, string(respBody))
}
}
fmt.Printf("\n%d/%d nodes registered with your wallet\n", registered, len(nodes))
if registered < len(nodes) {
fmt.Println("Nodes not found may need to join the cluster first, then re-run this command.")
}
return nil
},
}
func init() {
migrateConfCmd.Flags().StringVar(&migrateConfEnv, "env", "", "Environment to migrate (default: active)")
}

View File

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

View File

@ -0,0 +1,264 @@
// Package node — schema subcommand. Operator-facing commands for
// inspecting and applying the embedded gateway schema migrations against
// the local RQLite instance.
//
// `orama node schema status` — non-destructive: shows binary's required
// schema version, applied version, and pending
// migrations. Useful in rolling-upgrade
// monitoring.
//
// `orama node schema apply` — applies any pending migrations. Idempotent
// and safe to re-run; ALTER TABLE failures for
// existing columns are tolerated. Confirms
// before running unless --yes is passed.
//
// These are the long-term fix for the "schema lag after gateway-only
// upgrade" class of incident. See migrations/contract.go for the contract.
package node
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/DeBrosOfficial/network/migrations"
"github.com/DeBrosOfficial/network/pkg/config"
"github.com/DeBrosOfficial/network/pkg/rqlite"
"github.com/spf13/cobra"
"go.uber.org/zap"
_ "github.com/rqlite/gorqlite/stdlib"
)
var (
schemaDSN string
schemaYes bool
)
var schemaCmd = &cobra.Command{
Use: "schema",
Short: "Inspect and apply gateway schema migrations against the local RQLite",
Long: `Schema lifecycle commands.
The gateway binary embeds a set of SQL migrations. Each migration is numbered;
the highest number is the schema version the binary requires. After deploying
a new gateway binary, run 'orama node schema apply' on every namespace's RQLite
to bring the schema up to date otherwise function deploys fail at runtime
with cryptic missing-column errors.`,
}
var schemaStatusCmd = &cobra.Command{
Use: "status",
Short: "Show required vs applied schema version + pending migrations",
RunE: func(cmd *cobra.Command, args []string) error {
db, dsn, err := openSchemaDB()
if err != nil {
return err
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
applied, err := migrations.AppliedVersion(ctx, db)
if err != nil {
return fmt.Errorf("query applied version: %w", err)
}
required := migrations.RequiredVersion()
pending, err := migrations.PendingMigrations(ctx, db)
if err != nil {
return fmt.Errorf("compute pending: %w", err)
}
fmt.Printf("Connection: %s\n", dsn)
fmt.Printf("Required version: %d (highest migration in binary)\n", required)
fmt.Printf("Applied version: %d\n", applied)
switch {
case applied == required:
fmt.Printf("Status: ✓ up to date\n")
case applied > required:
fmt.Printf("Status: ⚠ database AHEAD of binary (%d > %d) — newer binary in cluster?\n",
applied, required)
default:
fmt.Printf("Status: ✗ BEHIND — %d migration(s) pending\n", len(pending))
}
if len(pending) > 0 {
fmt.Println("\nPending migrations:")
for _, m := range pending {
fmt.Printf(" %03d %s\n", m.Version, m.Name)
}
fmt.Println("\nRun 'sudo orama node schema apply' to apply them.")
}
return nil
},
}
var schemaApplyCmd = &cobra.Command{
Use: "apply",
Short: "Apply pending migrations to the local RQLite",
Long: `Apply every embedded migration not yet recorded in schema_migrations.
ALTER TABLE statements that target an already-existing column are tolerated
(the migration is marked complete). Other errors abort the run with the
schema in a partially-applied state re-running is safe because each
migration is independently versioned.`,
RunE: func(cmd *cobra.Command, args []string) error {
db, dsn, err := openSchemaDB()
if err != nil {
return err
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
pending, err := migrations.PendingMigrations(ctx, db)
if err != nil {
return fmt.Errorf("compute pending: %w", err)
}
if len(pending) == 0 {
fmt.Printf("No pending migrations. Schema is at version %d.\n", migrations.RequiredVersion())
return nil
}
fmt.Printf("Will apply %d migration(s) to %s:\n", len(pending), dsn)
for _, m := range pending {
fmt.Printf(" %03d %s\n", m.Version, m.Name)
}
if !schemaYes {
fmt.Print("\nProceed? [y/N]: ")
var ans string
_, _ = fmt.Scanln(&ans)
if strings.ToLower(strings.TrimSpace(ans)) != "y" {
fmt.Println("Aborted.")
return nil
}
}
// Use the existing migration runner — it does the same thing the
// gateway does at startup, with idempotent-error tolerance.
logger, _ := zap.NewProduction()
defer func() { _ = logger.Sync() }()
if err := rqlite.ApplyEmbeddedMigrations(ctx, db, migrations.FS, logger); err != nil {
return fmt.Errorf("apply failed: %w", err)
}
// Verify post-apply.
if err := migrations.AssertSchema(ctx, db); err != nil {
return fmt.Errorf("apply completed but schema still lags: %w", err)
}
fmt.Printf("\n✓ Schema now at version %d.\n", migrations.RequiredVersion())
return nil
},
}
// openSchemaDB returns a *sql.DB connected to the local RQLite instance,
// using the --dsn flag if provided, else discovering from the node config
// or falling back to localhost:5001.
func openSchemaDB() (*sql.DB, string, error) {
dsn := schemaDSN
if dsn == "" {
dsn = discoverLocalRQLiteDSN()
}
db, err := sql.Open("rqlite", dsn)
if err != nil {
return nil, "", fmt.Errorf("open rqlite: %w", err)
}
// Quick liveness check so we fail fast with a clear error.
pingCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
_ = db.Close()
return nil, "", fmt.Errorf("rqlite at %s unreachable: %w "+
"(hint: is RQLite running? try 'orama node status')", dsn, err)
}
return db, dsn, nil
}
// discoverLocalRQLiteDSN reads the node config to find the local RQLite
// port + credentials, falling back to localhost:5001 with no auth.
func discoverLocalRQLiteDSN() string {
const fallback = "http://localhost:5001"
cfgPath, err := config.DefaultPath("node.yaml")
if err != nil {
return fallback
}
if _, err := os.Stat(cfgPath); err != nil {
return fallback
}
cfgDir := filepath.Dir(cfgPath)
// Try to read RQLite credentials from the standard secrets path.
user, pass := readRQLiteCreds(cfgDir)
port := readRQLitePortFromConfig(cfgPath)
if port == 0 {
port = 5001
}
if user == "" {
return fmt.Sprintf("http://localhost:%d", port)
}
return fmt.Sprintf("http://%s:%s@localhost:%d", user, pass, port)
}
// readRQLiteCreds best-effort reads the user:pass from secrets files
// adjacent to the node config. Returns ("","") on any miss; the caller
// then connects without auth (which works on a local-only instance).
func readRQLiteCreds(cfgDir string) (string, string) {
type pair struct{ userFile, passFile string }
candidates := []pair{
{filepath.Join(cfgDir, "secrets", "rqlite-user"), filepath.Join(cfgDir, "secrets", "rqlite-password")},
}
for _, c := range candidates {
u, err := os.ReadFile(c.userFile)
if err != nil {
continue
}
p, err := os.ReadFile(c.passFile)
if err != nil {
continue
}
return strings.TrimSpace(string(u)), strings.TrimSpace(string(p))
}
return "", ""
}
// readRQLitePortFromConfig is a tiny YAML peek for `database.rqlite_port`.
// Avoids pulling the whole config loader; failure returns 0 → fallback used.
func readRQLitePortFromConfig(path string) int {
data, err := os.ReadFile(path)
if err != nil {
return 0
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "rqlite_port:") {
continue
}
var port int
_, err := fmt.Sscanf(line, "rqlite_port: %d", &port)
if err == nil {
return port
}
}
return 0
}
func init() {
schemaCmd.PersistentFlags().StringVar(&schemaDSN, "dsn", "",
"RQLite DSN (default: discover from node config or localhost:5001)")
schemaApplyCmd.Flags().BoolVar(&schemaYes, "yes", false,
"Skip the confirmation prompt")
schemaCmd.AddCommand(schemaStatusCmd)
schemaCmd.AddCommand(schemaApplyCmd)
}

View File

@ -0,0 +1,47 @@
package node
import (
"github.com/DeBrosOfficial/network/pkg/cli/production/setup"
"github.com/spf13/cobra"
)
var setupOpts setup.Options
var setupCmd = &cobra.Command{
Use: "setup",
Short: "Set up a fresh VPS as an Orama node",
Long: `Bootstrap a fresh VPS into a running Orama node in one command.
Creates an SSH key in rootwallet, installs it on the VPS, uploads the binary
archive, and runs the node install. For the first node, use --genesis to
create a new cluster.
Examples:
# Genesis node (first node, creates new cluster)
orama node setup --ip 1.2.3.4 --password 'vps-pass' --env devnet \
--base-domain orama-devnet.network --role nameserver --genesis
# Join existing cluster
orama node setup --ip 5.6.7.8 --password 'vps-pass' --env devnet \
--base-domain orama-devnet.network
# Join as nameserver
orama node setup --ip 9.10.11.12 --password 'vps-pass' --env devnet \
--base-domain orama-devnet.network --role nameserver`,
RunE: func(cmd *cobra.Command, args []string) error {
return setup.Run(setupOpts)
},
}
func init() {
setupCmd.Flags().StringVar(&setupOpts.IP, "ip", "", "Public IP address of the VPS (required)")
setupCmd.Flags().StringVar(&setupOpts.Env, "env", "", "Target environment (default: active)")
setupCmd.Flags().StringVar(&setupOpts.Role, "role", "node", "Node role: node or nameserver")
setupCmd.Flags().StringVar(&setupOpts.User, "user", "root", "SSH user on the VPS")
setupCmd.Flags().StringVar(&setupOpts.Password, "password", "", "One-time password for initial SSH access")
setupCmd.Flags().StringVar(&setupOpts.BaseDomain, "base-domain", "", "Base domain for the network")
setupCmd.Flags().StringVar(&setupOpts.Gateway, "gateway", "", "Gateway URL for invite tokens (e.g., http://1.2.3.4)")
setupCmd.Flags().BoolVar(&setupOpts.Genesis, "genesis", false, "Create a new cluster (first node)")
setupCmd.Flags().BoolVar(&setupOpts.AnyoneRelay, "anyone-relay", false, "Run as Anyone relay operator")
setupCmd.MarkFlagRequired("ip")
}

View File

@ -0,0 +1,58 @@
package nodescmd
import (
"fmt"
"os"
"text/tabwriter"
"github.com/DeBrosOfficial/network/pkg/cli"
"github.com/DeBrosOfficial/network/pkg/cli/noderesolver"
"github.com/spf13/cobra"
)
var envFlag string
// Cmd is the top-level "nodes" command — lists operator's nodes.
var Cmd = &cobra.Command{
Use: "nodes",
Short: "List your nodes across environments",
Long: `List all nodes owned by your wallet. Queries the network API
with your stored credentials, falling back to nodes.conf.
Requires: orama auth login (for API-based resolution)`,
RunE: func(cmd *cobra.Command, args []string) error {
env := envFlag
if env == "" {
active, err := cli.GetActiveEnvironment()
if err != nil {
return fmt.Errorf("failed to get active environment: %w", err)
}
env = active.Name
}
nodes, err := noderesolver.ResolveNodes(env)
if err != nil {
return fmt.Errorf("failed to resolve nodes: %w", err)
}
if len(nodes) == 0 {
fmt.Printf("No nodes found for environment %q\n", env)
fmt.Println("Register nodes with: orama node setup <ip> --env", env)
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintf(w, "IP\tROLE\tUSER\tENVIRONMENT\n")
for _, n := range nodes {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", n.Host, n.Role, n.User, n.Environment)
}
w.Flush()
fmt.Printf("\n%d node(s) in %s\n", len(nodes), env)
return nil
},
}
func init() {
Cmd.Flags().StringVar(&envFlag, "env", "", "Filter by environment (default: active environment)")
}

View File

@ -0,0 +1,259 @@
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: upload to hub, fan out server-to-server
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 (hub), then fans out
// server-to-server: the hub SCPs the archive to all other nodes in parallel
// and SSHes in to extract it.
//
// Key design — no SSH agent forwarding:
//
// The previous implementation loaded all N node keys into the system ssh-agent
// and used agent forwarding (-A) so the hub could reach targets. That caused
// "Too many authentication failures": when the hub connected to a target, the
// forwarded agent offered all N keys sequentially; if N exceeds the server's
// MaxAuthTries (default 6 on most distros), the server disconnects before the
// correct key is tried.
//
// Fix: PrepareNodeKeys (called by the parent command) already fetched and wrote
// each node's private key to a temp file (node.SSHKey). We base64-encode each
// key and embed it directly in the fanout bash script. On the hub, the script
// writes each key to its own mktemp file, uses -o IdentitiesOnly=yes -i $K
// (only ONE key offered per connection), and deletes the temp file immediately.
// No ssh-agent involved on either end; MaxAuthTries is irrelevant.
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 keeps the archive on disk after extracting — targets SCP it from here.
// The archive is removed from the hub at the end of this function.
hubExtractCmd := fmt.Sprintf("mkdir -p /opt/orama && tar xzf %s -C /opt/orama", remotePath)
// 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 script. Each target gets its own shell subshell that:
// 1. Writes the target's SSH private key to a mktemp file ($K).
// 2. SCPs the archive from the hub's local path to the target.
// 3. SSHes into the target to extract it.
// 4. Deletes $K — key material never lingers on the hub.
//
// All subshells run in parallel (&); a final `wait` collects them.
// The entire script is base64-encoded before transmission to avoid shell
// quoting conflicts (the script contains both single and double quotes).
var fanoutParts []string
for _, t := range targets {
keyBytes, err := os.ReadFile(t.SSHKey)
if err != nil {
// SSHKey was populated by PrepareNodeKeys; this should never
// happen unless the temp file was somehow deleted mid-run.
fmt.Printf(" Warning: could not read key for %s: %v (skipping)\n", t.Host, err)
continue
}
// base64 alphabet is [A-Za-z0-9+/=] — no shell metacharacters —
// safe to embed in single-quoted strings inside the script.
keyB64 := base64.StdEncoding.EncodeToString(keyBytes)
part := fmt.Sprintf(
"(K=$(mktemp) && echo '%s' | base64 -d >\"$K\" && chmod 600 \"$K\" && "+
"scp -o StrictHostKeyChecking=accept-new -o ConnectTimeout=30 -o IdentitiesOnly=yes -i \"$K\" %s %s@%s:%s && "+
"ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=30 -o IdentitiesOnly=yes -i \"$K\" %s@%s "+
"'sudo bash -c \"mkdir -p /opt/orama && tar xzf %s -C /opt/orama && rm -f %s\"' && "+
"rm -f \"$K\" && echo '%s: done' || (rm -f \"$K\" 2>/dev/null; echo '%s: FAILED')) &",
keyB64,
remotePath, t.User, t.Host, remotePath,
t.User, t.Host,
remotePath, remotePath,
t.Host, t.Host,
)
fanoutParts = append(fanoutParts, part)
}
fanoutParts = append(fanoutParts, "wait", "echo 'Fanout complete'")
fanoutScript := strings.Join(fanoutParts, "\n")
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)
// No agent forwarding — hub authenticates to each target using only
// that target's key (embedded above). IdentitiesOnly=yes ensures the
// hub's own host key is never accidentally offered to other nodes.
if err := remotessh.RunSSHStreaming(hub, runCmd); err != nil {
fmt.Printf(" Fanout failed: %v\n", err)
fmt.Println(" Some nodes may not have been updated")
}
// Clean up archive on hub
remotessh.RunSSHStreaming(hub, "rm -f "+remotePath)
fmt.Println("\nPush complete")
return nil
}
func findNewestArchive() string {
matches, _ := filepath.Glob("/tmp/orama-*-linux-*.tar.gz")
if len(matches) == 0 {
return ""
}
sort.Slice(matches, func(i, j int) bool {
fi, _ := os.Stat(matches[i])
fj, _ := os.Stat(matches[j])
if fi == nil || fj == nil {
return false
}
return fi.ModTime().After(fj.ModTime())
})
return matches[0]
}
func formatBytes(b int64) string {
const mb = 1024 * 1024
if b >= mb {
return fmt.Sprintf("%.1f MB", float64(b)/float64(mb))
}
return fmt.Sprintf("%d KB", b/1024)
}

View File

@ -0,0 +1,234 @@
package rolloutcmd
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/DeBrosOfficial/network/pkg/cli"
"github.com/DeBrosOfficial/network/pkg/cli/noderesolver"
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
"github.com/DeBrosOfficial/network/pkg/inspector"
"github.com/spf13/cobra"
)
var (
envFlag string
delaySec int
)
// Cmd is the top-level "rollout" command — build + push + rolling upgrade.
var Cmd = &cobra.Command{
Use: "rollout",
Short: "Rolling upgrade of your nodes",
Long: `Build, push, and perform a rolling upgrade on all your nodes in an environment.
Upgrades followers first, leader last, with health checks between each node.`,
RunE: func(cmd *cobra.Command, args []string) error {
env := envFlag
if env == "" {
active, err := cli.GetActiveEnvironment()
if err != nil {
return fmt.Errorf("failed to get active environment: %w", err)
}
env = active.Name
}
nodes, err := noderesolver.ResolveNodes(env)
if err != nil {
return fmt.Errorf("failed to resolve nodes: %w", err)
}
if len(nodes) == 0 {
return fmt.Errorf("no nodes found for environment %q", env)
}
cleanup, err := remotessh.PrepareNodeKeys(nodes)
if err != nil {
return fmt.Errorf("failed to prepare SSH keys: %w", err)
}
defer cleanup()
fmt.Printf("Rolling out to %d node(s) in %s\n\n", len(nodes), env)
// Step 1: Find archive
archivePath := findNewestArchive()
if archivePath == "" {
return fmt.Errorf("no binary archive found in /tmp/ (run `orama build` first)")
}
info, err := os.Stat(archivePath)
if err != nil {
return fmt.Errorf("stat archive %s: %w", archivePath, err)
}
fmt.Printf("Archive: %s (%s)\n\n", filepath.Base(archivePath), formatBytes(info.Size()))
// Step 2: Push archive to all nodes
fmt.Println("Pushing archive to all nodes...")
if err := pushArchive(nodes, archivePath); err != nil {
return err
}
// Step 3: Rolling upgrade — followers first, leader last
fmt.Println("\nRolling upgrade (followers first, leader last)...")
leaderIdx := findLeaderIndex(nodes)
if leaderIdx < 0 {
fmt.Fprintf(os.Stderr, " Warning: could not detect RQLite leader, upgrading in order\n")
}
// Determine SSH options based on environment
var sshOpts []remotessh.SSHOption
if env == "sandbox" {
sshOpts = append(sshOpts, remotessh.WithNoHostKeyCheck())
}
delay := time.Duration(delaySec) * time.Second
// Upgrade non-leaders first
count := 0
for i := range nodes {
if i == leaderIdx {
continue
}
count++
if err := upgradeNode(nodes[i], count, len(nodes), sshOpts); err != nil {
return err
}
if count < len(nodes) {
fmt.Printf(" Waiting %s before next node...\n", delay)
time.Sleep(delay)
}
}
// Upgrade leader last
if leaderIdx >= 0 {
count++
if err := upgradeNode(nodes[leaderIdx], count, len(nodes), sshOpts); err != nil {
return err
}
}
fmt.Printf("\nRollout complete for %s (%d nodes)\n", env, len(nodes))
return nil
},
}
func init() {
Cmd.Flags().StringVar(&envFlag, "env", "", "Environment (default: active)")
Cmd.Flags().IntVar(&delaySec, "delay", 30, "Seconds to wait between node upgrades")
}
// findLeaderIndex returns the index of the RQLite leader, or -1 if unknown.
func findLeaderIndex(nodes []inspector.Node) int {
for i, n := range nodes {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
result := inspector.RunSSH(ctx, n, "curl -sf http://localhost:5001/status 2>/dev/null | grep -o '\"state\":\"[^\"]*\"'")
cancel()
if result.OK() && strings.Contains(result.Stdout, "Leader") {
return i
}
}
return -1
}
// upgradeNode performs orama node upgrade --restart on a single node.
func upgradeNode(node inspector.Node, current, total int, sshOpts []remotessh.SSHOption) error {
fmt.Printf(" [%d/%d] Upgrading %s...\n", current, total, node.Host)
// Pre-replace orama CLI binary to avoid ETXTBSY
preReplace := "rm -f /usr/local/bin/orama && cp /opt/orama/bin/orama /usr/local/bin/orama"
if err := remotessh.RunSSHStreaming(node, preReplace, sshOpts...); err != nil {
return fmt.Errorf("pre-replace orama binary on %s: %w", node.Host, err)
}
if err := remotessh.RunSSHStreaming(node, "orama node upgrade --restart", sshOpts...); err != nil {
return fmt.Errorf("upgrade %s: %w", node.Host, err)
}
// Wait for health
fmt.Printf(" Checking health...")
if err := waitForHealth(node, 2*time.Minute); err != nil {
fmt.Printf(" WARN: %v\n", err)
} else {
fmt.Println(" OK")
}
return nil
}
// pushArchive uploads the archive to the first node, then fans out server-to-server.
func pushArchive(nodes []inspector.Node, archivePath string) error {
if len(nodes) == 0 {
return nil
}
remotePath := "/tmp/" + filepath.Base(archivePath)
// Upload to first node
hub := nodes[0]
fmt.Printf(" Uploading to %s...\n", hub.Host)
if err := remotessh.UploadFile(hub, archivePath, remotePath); err != nil {
return fmt.Errorf("upload to %s: %w", hub.Host, err)
}
// Extract on hub
extractCmd := fmt.Sprintf("mkdir -p /opt/orama && tar xzf %s -C /opt/orama && rm -f %s", remotePath, remotePath)
if err := remotessh.RunSSHStreaming(hub, extractCmd); err != nil {
return fmt.Errorf("extract on %s: %w", hub.Host, err)
}
// For remaining nodes, upload directly and extract
for _, n := range nodes[1:] {
fmt.Printf(" Uploading to %s...\n", n.Host)
if err := remotessh.UploadFile(n, archivePath, remotePath); err != nil {
return fmt.Errorf("upload to %s: %w", n.Host, err)
}
if err := remotessh.RunSSHStreaming(n, extractCmd); err != nil {
return fmt.Errorf("extract on %s: %w", n.Host, err)
}
}
return nil
}
// waitForHealth polls RQLite health on a node until it reaches Leader or Follower state.
func waitForHealth(node inspector.Node, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
result := inspector.RunSSH(ctx, node, "curl -sf http://localhost:5001/status 2>/dev/null | grep -o '\"state\":\"[^\"]*\"'")
cancel()
if result.OK() && (strings.Contains(result.Stdout, "Leader") || strings.Contains(result.Stdout, "Follower")) {
return nil
}
time.Sleep(3 * time.Second)
}
return fmt.Errorf("timed out waiting for healthy state on %s", node.Host)
}
// findNewestArchive finds the newest orama binary archive in /tmp/.
func findNewestArchive() string {
matches, err := filepath.Glob("/tmp/orama-*-linux-*.tar.gz")
if err != nil || len(matches) == 0 {
return ""
}
sort.Slice(matches, func(i, j int) bool {
fi, _ := os.Stat(matches[i])
fj, _ := os.Stat(matches[j])
if fi == nil || fj == nil {
return false
}
return fi.ModTime().After(fj.ModTime())
})
return matches[0]
}
func formatBytes(b int64) string {
const mb = 1024 * 1024
if b >= mb {
return fmt.Sprintf("%.1f MB", float64(b)/float64(mb))
}
return fmt.Sprintf("%d KB", b/1024)
}

View File

@ -0,0 +1,101 @@
package sshcmd
import (
"fmt"
"os"
"os/exec"
"github.com/DeBrosOfficial/network/pkg/cli"
"github.com/DeBrosOfficial/network/pkg/cli/noderesolver"
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
"github.com/DeBrosOfficial/network/pkg/inspector"
"github.com/spf13/cobra"
)
var envFlag string
// Cmd is the top-level "ssh" command — SSH into any node by IP or hostname.
var Cmd = &cobra.Command{
Use: "ssh <ip-or-hostname> [-- command]",
Short: "SSH into a node",
Long: `SSH into a node by IP address or hostname.
Resolves the SSH key from rootwallet automatically.
Pass a command after the IP to run it non-interactively:
orama ssh 1.2.3.4 'sudo systemctl status orama-node'`,
Args: cobra.MinimumNArgs(1),
DisableFlagParsing: false,
RunE: func(cmd *cobra.Command, args []string) error {
target := args[0]
remoteCmd := ""
if len(args) > 1 {
remoteCmd = args[1]
}
env := envFlag
if env == "" {
active, err := cli.GetActiveEnvironment()
if err != nil {
return fmt.Errorf("failed to get active environment: %w", err)
}
env = active.Name
}
// Resolve nodes to find the target
nodes, err := noderesolver.ResolveNodes(env)
if err != nil {
return fmt.Errorf("failed to resolve nodes: %w", err)
}
// Match by IP
for _, n := range nodes {
if n.Host == target {
return sshInto(n, remoteCmd)
}
}
// Not found — try direct SSH with default vault target
fmt.Printf("Node %q not found in %s nodes, attempting direct SSH...\n", target, env)
return sshInto(inspector.Node{
Host: target,
User: "root",
VaultTarget: target + "/root",
}, remoteCmd)
},
}
func init() {
Cmd.Flags().StringVar(&envFlag, "env", "", "Environment to search (default: active)")
}
func sshInto(node inspector.Node, remoteCmd string) error {
nodes := []inspector.Node{node}
cleanup, err := remotessh.PrepareNodeKeys(nodes)
if err != nil {
return fmt.Errorf("failed to resolve SSH key: %w", err)
}
defer cleanup()
keyPath := nodes[0].SSHKey
sshBin, err := exec.LookPath("ssh")
if err != nil {
return fmt.Errorf("ssh not found in PATH: %w", err)
}
sshArgs := []string{
"-i", keyPath,
"-o", "StrictHostKeyChecking=accept-new",
"-o", "IdentitiesOnly=yes",
fmt.Sprintf("%s@%s", node.User, node.Host),
}
if remoteCmd != "" {
sshArgs = append(sshArgs, remoteCmd)
}
sshCmd := exec.Command(sshBin, sshArgs...)
sshCmd.Stdin = os.Stdin
sshCmd.Stdout = os.Stdout
sshCmd.Stderr = os.Stderr
return sshCmd.Run()
}

View File

@ -0,0 +1,143 @@
package statuscmd
import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
"text/tabwriter"
"time"
"github.com/DeBrosOfficial/network/pkg/cli"
"github.com/DeBrosOfficial/network/pkg/cli/noderesolver"
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
"github.com/DeBrosOfficial/network/pkg/inspector"
"github.com/spf13/cobra"
)
var (
envFlag string
jsonFlag bool
)
// Cmd is the top-level "status" command — health check for operator's nodes.
var Cmd = &cobra.Command{
Use: "status",
Short: "Show health status of your nodes",
Long: `Check the health of all your nodes in an environment.
SSHes into each node and runs orama node report to collect health data.`,
RunE: func(cmd *cobra.Command, args []string) error {
env := envFlag
if env == "" {
active, err := cli.GetActiveEnvironment()
if err != nil {
return fmt.Errorf("failed to get active environment: %w", err)
}
env = active.Name
}
nodes, err := noderesolver.ResolveNodes(env)
if err != nil {
return fmt.Errorf("failed to resolve nodes: %w", err)
}
if len(nodes) == 0 {
fmt.Printf("No nodes found for environment %q\n", env)
return nil
}
cleanup, err := remotessh.PrepareNodeKeys(nodes)
if err != nil {
return fmt.Errorf("failed to prepare SSH keys: %w", err)
}
defer cleanup()
fmt.Printf("Checking %d node(s) in %s...\n\n", len(nodes), env)
type nodeResult struct {
Host string `json:"host"`
Role string `json:"role"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
results := make([]nodeResult, len(nodes))
var wg sync.WaitGroup
for i, n := range nodes {
wg.Add(1)
go func(idx int, node inspector.Node) {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := inspector.RunSSH(ctx, node, "sudo orama node report --json")
nr := nodeResult{Host: node.Host, Role: node.Role}
if !result.OK() {
nr.Status = "unreachable"
nr.Error = fmt.Sprintf("SSH failed (exit %d)", result.ExitCode)
if result.Stderr != "" {
nr.Error = result.Stderr
if len(nr.Error) > 100 {
nr.Error = nr.Error[:100] + "..."
}
}
results[idx] = nr
return
}
var report struct {
Gateway struct {
Responsive bool `json:"responsive"`
} `json:"gateway"`
RQLite struct {
RaftState string `json:"raft_state"`
} `json:"rqlite"`
}
if err := json.Unmarshal([]byte(result.Stdout), &report); err != nil {
nr.Status = "unknown"
nr.Error = "failed to parse report"
results[idx] = nr
return
}
if report.Gateway.Responsive && (report.RQLite.RaftState == "Leader" || report.RQLite.RaftState == "Follower") {
nr.Status = "healthy"
} else {
nr.Status = "degraded"
}
results[idx] = nr
}(i, n)
}
wg.Wait()
if jsonFlag {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(results)
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintf(w, "IP\tROLE\tSTATUS\tDETAILS\n")
healthy := 0
for _, r := range results {
details := r.Error
if r.Status == "healthy" {
healthy++
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Host, r.Role, r.Status, details)
}
w.Flush()
fmt.Printf("\n%d/%d nodes healthy\n", healthy, len(results))
return nil
},
}
func init() {
Cmd.Flags().StringVar(&envFlag, "env", "", "Environment (default: active)")
Cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON")
}

View File

@ -164,30 +164,8 @@ func handleEnvAdd(args []string) {
os.Exit(1)
}
envConfig, err := LoadEnvironmentConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Failed to load environment config: %v\n", err)
os.Exit(1)
}
// Check if environment already exists
for _, env := range envConfig.Environments {
if env.Name == name {
fmt.Fprintf(os.Stderr, "❌ Environment '%s' already exists\n", name)
os.Exit(1)
}
}
// Add new environment
envConfig.Environments = append(envConfig.Environments, Environment{
Name: name,
GatewayURL: gatewayURL,
Description: description,
IsActive: false,
})
if err := SaveEnvironmentConfig(envConfig); err != nil {
fmt.Fprintf(os.Stderr, "❌ Failed to save environment config: %v\n", err)
if err := AddEnvironment(name, gatewayURL, description); err != nil {
fmt.Fprintf(os.Stderr, "❌ Failed to add environment: %v\n", err)
os.Exit(1)
}
@ -206,37 +184,8 @@ func handleEnvRemove(args []string) {
name := args[0]
envConfig, err := LoadEnvironmentConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Failed to load environment config: %v\n", err)
os.Exit(1)
}
// Find and remove environment
found := false
newEnvs := make([]Environment, 0, len(envConfig.Environments))
for _, env := range envConfig.Environments {
if env.Name == name {
found = true
continue
}
newEnvs = append(newEnvs, env)
}
if !found {
fmt.Fprintf(os.Stderr, "❌ Environment '%s' not found\n", name)
os.Exit(1)
}
envConfig.Environments = newEnvs
// If we removed the active environment, switch to devnet
if envConfig.ActiveEnvironment == name {
envConfig.ActiveEnvironment = "devnet"
}
if err := SaveEnvironmentConfig(envConfig); err != nil {
fmt.Fprintf(os.Stderr, "❌ Failed to save environment config: %v\n", err)
if err := RemoveEnvironment(name); err != nil {
fmt.Fprintf(os.Stderr, "❌ Failed to remove environment: %v\n", err)
os.Exit(1)
}

View File

@ -45,8 +45,11 @@ var DefaultEnvironments = []Environment{
},
}
// GetEnvironmentConfigPath returns the path to the environment config file
func GetEnvironmentConfigPath() (string, error) {
// getEnvironmentConfigPathFn is the function used to resolve the config path.
// Tests override this to point at a temp file.
var getEnvironmentConfigPathFn = getEnvironmentConfigPathDefault
func getEnvironmentConfigPathDefault() (string, error) {
configDir, err := config.ConfigDir()
if err != nil {
return "", fmt.Errorf("failed to get config directory: %w", err)
@ -54,6 +57,11 @@ func GetEnvironmentConfigPath() (string, error) {
return filepath.Join(configDir, "environments.json"), nil
}
// GetEnvironmentConfigPath returns the path to the environment config file
func GetEnvironmentConfigPath() (string, error) {
return getEnvironmentConfigPathFn()
}
// LoadEnvironmentConfig loads the environment configuration
func LoadEnvironmentConfig() (*EnvironmentConfig, error) {
path, err := GetEnvironmentConfigPath()
@ -170,6 +178,63 @@ func GetEnvironmentByName(name string) (*Environment, error) {
return nil, fmt.Errorf("environment '%s' not found", name)
}
// AddEnvironment adds a new environment or updates an existing one.
// If an environment with the same name already exists, its gateway URL and
// description are updated in place.
func AddEnvironment(name, gatewayURL, description string) error {
envConfig, err := LoadEnvironmentConfig()
if err != nil {
return err
}
for i, env := range envConfig.Environments {
if env.Name == name {
envConfig.Environments[i].GatewayURL = gatewayURL
envConfig.Environments[i].Description = description
return SaveEnvironmentConfig(envConfig)
}
}
envConfig.Environments = append(envConfig.Environments, Environment{
Name: name,
GatewayURL: gatewayURL,
Description: description,
})
return SaveEnvironmentConfig(envConfig)
}
// RemoveEnvironment removes an environment by name. If the removed environment
// was active, the active environment falls back to "devnet".
func RemoveEnvironment(name string) error {
envConfig, err := LoadEnvironmentConfig()
if err != nil {
return err
}
newEnvs := make([]Environment, 0, len(envConfig.Environments))
found := false
for _, env := range envConfig.Environments {
if env.Name == name {
found = true
continue
}
newEnvs = append(newEnvs, env)
}
if !found {
return nil // already absent, nothing to do
}
envConfig.Environments = newEnvs
if envConfig.ActiveEnvironment == name {
envConfig.ActiveEnvironment = "devnet"
}
return SaveEnvironmentConfig(envConfig)
}
// InitializeEnvironments initializes the environment config with defaults
func InitializeEnvironments() error {
path, err := GetEnvironmentConfigPath()

View File

@ -0,0 +1,131 @@
package cli
import (
"encoding/json"
"os"
"testing"
)
// writeTestConfig writes an EnvironmentConfig to a temp file and returns
// a helper that patches GetEnvironmentConfigPath to return that path.
// The returned cleanup restores the original function.
func writeTestConfig(t *testing.T, cfg *EnvironmentConfig) func() {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "envconfig-*.json")
if err != nil {
t.Fatalf("create temp file: %v", err)
}
data, _ := json.MarshalIndent(cfg, "", " ")
if _, err := f.Write(data); err != nil {
t.Fatalf("write temp file: %v", err)
}
f.Close()
origFn := getEnvironmentConfigPathFn
getEnvironmentConfigPathFn = func() (string, error) { return f.Name(), nil }
return func() { getEnvironmentConfigPathFn = origFn }
}
func defaultTestConfig() *EnvironmentConfig {
return &EnvironmentConfig{
Environments: []Environment{
{Name: "sandbox", GatewayURL: "https://dbrs.space", Description: "Sandbox cluster"},
{Name: "devnet", GatewayURL: "https://orama-devnet.network", Description: "Development network"},
{Name: "testnet", GatewayURL: "https://orama-testnet.network", Description: "Test network"},
},
ActiveEnvironment: "sandbox",
}
}
func TestAddEnvironment_new(t *testing.T) {
cleanup := writeTestConfig(t, defaultTestConfig())
defer cleanup()
if err := AddEnvironment("staging", "https://staging.example.com", "Staging env"); err != nil {
t.Fatalf("AddEnvironment: %v", err)
}
env, err := GetEnvironmentByName("staging")
if err != nil {
t.Fatalf("GetEnvironmentByName: %v", err)
}
if env.GatewayURL != "https://staging.example.com" {
t.Errorf("GatewayURL = %q, want %q", env.GatewayURL, "https://staging.example.com")
}
if env.Description != "Staging env" {
t.Errorf("Description = %q, want %q", env.Description, "Staging env")
}
}
func TestAddEnvironment_update(t *testing.T) {
cleanup := writeTestConfig(t, defaultTestConfig())
defer cleanup()
if err := AddEnvironment("sandbox", "https://new.example.com", "Updated sandbox"); err != nil {
t.Fatalf("AddEnvironment: %v", err)
}
env, err := GetEnvironmentByName("sandbox")
if err != nil {
t.Fatalf("GetEnvironmentByName: %v", err)
}
if env.GatewayURL != "https://new.example.com" {
t.Errorf("GatewayURL = %q, want %q", env.GatewayURL, "https://new.example.com")
}
if env.Description != "Updated sandbox" {
t.Errorf("Description = %q, want %q", env.Description, "Updated sandbox")
}
// Verify upsert didn't create a duplicate
cfg, _ := LoadEnvironmentConfig()
count := 0
for _, e := range cfg.Environments {
if e.Name == "sandbox" {
count++
}
}
if count != 1 {
t.Errorf("sandbox entries = %d, want 1", count)
}
}
func TestRemoveEnvironment_existing(t *testing.T) {
cleanup := writeTestConfig(t, defaultTestConfig())
defer cleanup()
if err := RemoveEnvironment("testnet"); err != nil {
t.Fatalf("RemoveEnvironment: %v", err)
}
_, err := GetEnvironmentByName("testnet")
if err == nil {
t.Error("expected error for removed environment, got nil")
}
}
func TestRemoveEnvironment_absent(t *testing.T) {
cleanup := writeTestConfig(t, defaultTestConfig())
defer cleanup()
if err := RemoveEnvironment("nonexistent"); err != nil {
t.Errorf("RemoveEnvironment(absent) = %v, want nil", err)
}
}
func TestRemoveEnvironment_active_falls_back(t *testing.T) {
cleanup := writeTestConfig(t, defaultTestConfig())
defer cleanup()
if err := RemoveEnvironment("sandbox"); err != nil {
t.Fatalf("RemoveEnvironment: %v", err)
}
cfg, err := LoadEnvironmentConfig()
if err != nil {
t.Fatalf("LoadEnvironmentConfig: %v", err)
}
if cfg.ActiveEnvironment != "devnet" {
t.Errorf("ActiveEnvironment = %q, want %q", cfg.ActiveEnvironment, "devnet")
}
}

View File

@ -24,6 +24,14 @@ type FunctionConfig struct {
Timeout int `yaml:"timeout"`
Retry RetryConfig `yaml:"retry"`
Env map[string]string `yaml:"env"`
// Persistent WebSocket settings — when WSPersistent is true, the function
// must export ws_open / ws_frame / ws_close instead of running per-frame
// stateless. See core/plans/platform/06_PERSISTENT_WS_FUNCTIONS.md.
WSPersistent bool `yaml:"ws_persistent"`
WSIdleTimeoutSec int `yaml:"ws_idle_timeout_sec"`
WSMaxFrameBytes int `yaml:"ws_max_frame_bytes"`
WSMaxInflightPerConn int `yaml:"ws_max_inflight_per_conn"`
}
// RetryConfig holds retry settings.
@ -198,11 +206,28 @@ func uploadWASMFunction(wasmPath string, cfg *FunctionConfig) (map[string]interf
writer.WriteField("retry_count", strconv.Itoa(cfg.Retry.Count))
writer.WriteField("retry_delay_seconds", strconv.Itoa(cfg.Retry.Delay))
// Add env vars as metadata JSON
// Build metadata JSON. The deploy handler json.Unmarshal()s this into
// FunctionDefinition first, then overlays the explicit form fields below.
// Any field that has no explicit form-field equivalent (env vars, the
// ws_* persistent settings) MUST live in this blob.
metaObj := map[string]interface{}{}
if len(cfg.Env) > 0 {
metadata, _ := json.Marshal(map[string]interface{}{
"env_vars": cfg.Env,
})
metaObj["env_vars"] = cfg.Env
}
if cfg.WSPersistent {
metaObj["ws_persistent"] = true
}
if cfg.WSIdleTimeoutSec > 0 {
metaObj["ws_idle_timeout_sec"] = cfg.WSIdleTimeoutSec
}
if cfg.WSMaxFrameBytes > 0 {
metaObj["ws_max_frame_bytes"] = cfg.WSMaxFrameBytes
}
if cfg.WSMaxInflightPerConn > 0 {
metaObj["ws_max_inflight_per_conn"] = cfg.WSMaxInflightPerConn
}
if len(metaObj) > 0 {
metadata, _ := json.Marshal(metaObj)
writer.WriteField("metadata", string(metadata))
}

View File

@ -3,31 +3,58 @@ package functions
import (
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
)
var logsLimit int
var (
logsLimit int
logsWASMOnly bool
)
// LogsCmd retrieves function execution logs.
// LogsCmd retrieves function invocation history.
//
// Default view: invocation history (always populated when the function has
// been invoked) — request_id, status, duration, error_message, plus any
// WASM-emitted log entries nested per record.
//
// --wasm-only switches to the legacy view that returns ONLY entries
// emitted by the function via log_info / log_error (often empty).
var LogsCmd = &cobra.Command{
Use: "logs <name>",
Short: "Get execution logs for a function",
Long: "Retrieves the most recent execution logs for a deployed function.",
Args: cobra.ExactArgs(1),
RunE: runLogs,
Short: "Get invocation history for a function",
Long: `Retrieves the most recent invocations for a deployed function.
Each invocation record shows: timestamp, request_id, status, duration_ms,
and (if any) the error message. WASM functions that emit log entries via
log_info / log_error have those entries nested under each record.
Pass --wasm-only to retrieve only the WASM-emitted log lines (legacy
behavior; rarely useful on functions that don't call log_info).`,
Args: cobra.ExactArgs(1),
RunE: runLogs,
}
func init() {
LogsCmd.Flags().IntVar(&logsLimit, "limit", 50, "Maximum number of log entries to retrieve")
LogsCmd.Flags().IntVar(&logsLimit, "limit", 50, "Maximum number of records to retrieve")
LogsCmd.Flags().BoolVar(&logsWASMOnly, "wasm-only", false,
"Show only WASM-emitted log entries (legacy view)")
}
func runLogs(cmd *cobra.Command, args []string) error {
name := args[0]
endpoint := "/v1/functions/" + name + "/logs"
q := []string{}
if logsLimit > 0 {
endpoint += "?limit=" + strconv.Itoa(logsLimit)
q = append(q, "limit="+strconv.Itoa(logsLimit))
}
if logsWASMOnly {
q = append(q, "wasm_only=1")
}
if len(q) > 0 {
endpoint += "?" + strings.Join(q, "&")
}
result, err := apiGet(endpoint)
@ -35,9 +62,64 @@ func runLogs(cmd *cobra.Command, args []string) error {
return err
}
if logsWASMOnly {
return printWASMLogs(name, result)
}
return printInvocations(name, result)
}
// printInvocations renders the default invocation-history view.
func printInvocations(name string, result map[string]interface{}) error {
invs, ok := result["invocations"].([]interface{})
if !ok || len(invs) == 0 {
fmt.Printf("No invocations found for function %q.\n", name)
return nil
}
for _, entry := range invs {
inv, ok := entry.(map[string]interface{})
if !ok {
continue
}
started := valStr(inv, "started_at")
status := valStr(inv, "status")
reqID := valStr(inv, "request_id")
duration := valNumberAsString(inv, "duration_ms")
errMsg := valStr(inv, "error_message")
// Header line per invocation.
fmt.Printf("[%s] %s request=%s duration=%sms\n",
started, strings.ToUpper(status), reqID, duration)
if errMsg != "" {
fmt.Printf(" error: %s\n", errMsg)
}
// Nested WASM logs (if any).
if wasmLogs, ok := inv["wasm_logs"].([]interface{}); ok {
for _, l := range wasmLogs {
le, ok := l.(map[string]interface{})
if !ok {
continue
}
fmt.Printf(" %s [%s] %s\n",
valStr(le, "timestamp"),
strings.ToUpper(valStr(le, "level")),
valStr(le, "message"))
}
}
}
fmt.Printf("\nShowing %d invocation(s). Use --wasm-only for the legacy log-line view.\n",
len(invs))
return nil
}
// printWASMLogs renders the legacy WASM-only view.
func printWASMLogs(name string, result map[string]interface{}) error {
logs, ok := result["logs"].([]interface{})
if !ok || len(logs) == 0 {
fmt.Printf("No logs found for function %q.\n", name)
fmt.Printf("No WASM-emitted logs found for function %q. "+
"Tip: drop --wasm-only to see invocation history.\n", name)
return nil
}
@ -55,3 +137,23 @@ func runLogs(cmd *cobra.Command, args []string) error {
fmt.Printf("\nShowing %d log(s)\n", len(logs))
return nil
}
// valNumberAsString formats a JSON number field as a clean integer string.
func valNumberAsString(m map[string]interface{}, key string) string {
v, ok := m[key]
if !ok || v == nil {
return "0"
}
switch n := v.(type) {
case float64:
return strconv.FormatInt(int64(n), 10)
case int:
return strconv.Itoa(n)
case int64:
return strconv.FormatInt(n, 10)
case string:
return n
default:
return fmt.Sprintf("%v", n)
}
}

View File

@ -6,34 +6,47 @@ import (
"fmt"
"io"
"text/tabwriter"
"time"
"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 +70,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 +104,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
}
@ -107,32 +127,83 @@ func runTriggersList(cmd *cobra.Command, args []string) error {
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tTOPIC\tENABLED")
// Bug #65 audit: the previous CLI rendered only ID/TOPIC/ENABLED, so cron
// triggers appeared as mystery blank-topic rows. The handler returns a
// `kind` discriminator plus pubsub-only `topic` or cron-only
// `cron_expression` / `next_run_at` / `last_run_at`; the CLI now renders
// both kinds in a single unified table.
fmt.Fprintln(w, "ID\tKIND\tSCHEDULE/TOPIC\tNEXT RUN\tLAST RUN\tENABLED")
for _, t := range triggers {
tr, ok := t.(map[string]interface{})
if !ok {
continue
}
id, _ := tr["ID"].(string)
if id == "" {
id, _ = tr["id"].(string)
id := stringField(tr, "id", "ID")
kind := stringField(tr, "kind", "Kind")
// Backward compat: pre-#65 servers returned only `topic` with no
// `kind` field. Treat those as pubsub.
if kind == "" {
kind = "pubsub"
}
topic, _ := tr["Topic"].(string)
if topic == "" {
topic, _ = tr["topic"].(string)
var what, nextRun, lastRun string
switch kind {
case "cron":
what = stringField(tr, "cron_expression", "CronExpression")
nextRun = formatCronTimestamp(tr["next_run_at"])
lastRun = formatCronTimestamp(tr["last_run_at"])
default: // pubsub or unknown
what = stringField(tr, "topic", "Topic")
nextRun = "-"
lastRun = "-"
}
enabled := true
if e, ok := tr["Enabled"].(bool); ok {
enabled = e
} else if e, ok := tr["enabled"].(bool); ok {
enabled = e
}
fmt.Fprintf(w, "%s\t%s\t%v\n", id, topic, enabled)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%v\n", id, kind, what, nextRun, lastRun, enabled)
}
w.Flush()
return nil
}
// stringField pulls a string from a JSON-decoded map under any of the
// supplied keys, in order. The handler emits snake_case (`cron_expression`)
// while older Go-tagged structs may surface PascalCase — try both.
func stringField(m map[string]interface{}, keys ...string) string {
for _, k := range keys {
if v, ok := m[k].(string); ok && v != "" {
return v
}
}
return ""
}
// formatCronTimestamp renders a JSON timestamp from the handler in a compact
// human-readable form. Returns "-" for nil / unparseable values so the CLI
// table stays aligned for never-run / pubsub rows.
func formatCronTimestamp(v interface{}) string {
if v == nil {
return "-"
}
s, ok := v.(string)
if !ok || s == "" {
return "-"
}
// Try RFC3339 first (Go's default time.Time JSON encoding); fall back to
// the raw string so unexpected formats don't disappear silently.
if ts, err := time.Parse(time.RFC3339, s); err == nil {
return ts.UTC().Format("2006-01-02 15:04:05 UTC")
}
if ts, err := time.Parse(time.RFC3339Nano, s); err == nil {
return ts.UTC().Format("2006-01-02 15:04:05 UTC")
}
return s
}
func runTriggersDelete(cmd *cobra.Command, args []string) error {
funcName := args[0]
triggerID := args[1]

View File

@ -0,0 +1,114 @@
package functions
import (
"testing"
)
// ----------------------------------------------------------------------------
// stringField — pulls value from JSON-decoded map under any of the given keys
// ----------------------------------------------------------------------------
func TestStringField_prefersFirstKey(t *testing.T) {
m := map[string]interface{}{
"id": "first",
"ID": "second",
}
if got := stringField(m, "id", "ID"); got != "first" {
t.Errorf("stringField = %q, want %q", got, "first")
}
}
func TestStringField_fallsThroughWhenFirstMissing(t *testing.T) {
m := map[string]interface{}{
"ID": "second",
}
if got := stringField(m, "id", "ID"); got != "second" {
t.Errorf("stringField = %q, want %q", got, "second")
}
}
func TestStringField_emptyValueSkipped(t *testing.T) {
// An empty string under the first key MUST fall through to subsequent
// keys, otherwise empty pubsub `topic` fields would shadow valid
// PascalCase `Topic`.
m := map[string]interface{}{
"id": "",
"ID": "fallback",
}
if got := stringField(m, "id", "ID"); got != "fallback" {
t.Errorf("stringField = %q, want %q", got, "fallback")
}
}
func TestStringField_nonStringValueSkipped(t *testing.T) {
m := map[string]interface{}{
"id": 42,
"ID": "ok",
}
if got := stringField(m, "id", "ID"); got != "ok" {
t.Errorf("stringField = %q, want %q", got, "ok")
}
}
func TestStringField_allMissingReturnsEmpty(t *testing.T) {
m := map[string]interface{}{"other": "value"}
if got := stringField(m, "id", "ID"); got != "" {
t.Errorf("stringField = %q, want empty", got)
}
}
// ----------------------------------------------------------------------------
// formatCronTimestamp — RFC3339 -> UTC display, "-" for missing/unparseable
// ----------------------------------------------------------------------------
func TestFormatCronTimestamp_nilReturnsDash(t *testing.T) {
if got := formatCronTimestamp(nil); got != "-" {
t.Errorf("formatCronTimestamp(nil) = %q, want %q", got, "-")
}
}
func TestFormatCronTimestamp_emptyStringReturnsDash(t *testing.T) {
if got := formatCronTimestamp(""); got != "-" {
t.Errorf("formatCronTimestamp(\"\") = %q, want %q", got, "-")
}
}
func TestFormatCronTimestamp_nonStringReturnsDash(t *testing.T) {
if got := formatCronTimestamp(42); got != "-" {
t.Errorf("formatCronTimestamp(42) = %q, want %q", got, "-")
}
}
func TestFormatCronTimestamp_rfc3339(t *testing.T) {
got := formatCronTimestamp("2025-05-08T03:00:00Z")
want := "2025-05-08 03:00:00 UTC"
if got != want {
t.Errorf("formatCronTimestamp = %q, want %q", got, want)
}
}
func TestFormatCronTimestamp_rfc3339Nano(t *testing.T) {
got := formatCronTimestamp("2025-05-08T03:00:00.123456789Z")
want := "2025-05-08 03:00:00 UTC"
if got != want {
t.Errorf("formatCronTimestamp nano = %q, want %q", got, want)
}
}
func TestFormatCronTimestamp_rfc3339WithOffset(t *testing.T) {
// Non-UTC offsets must be normalised to UTC for the display.
got := formatCronTimestamp("2025-05-08T05:00:00+02:00")
want := "2025-05-08 03:00:00 UTC"
if got != want {
t.Errorf("formatCronTimestamp offset = %q, want %q", got, want)
}
}
func TestFormatCronTimestamp_unparseableFallsBackToRaw(t *testing.T) {
// If the server returns an unexpected timestamp shape, surface it
// rather than silently dropping to "-" — operator visibility wins.
got := formatCronTimestamp("not-a-timestamp")
if got != "not-a-timestamp" {
t.Errorf("formatCronTimestamp unparseable = %q, want raw passthrough", got)
}
}

View File

@ -124,6 +124,7 @@ func DeriveAlerts(snap *ClusterSnapshot) []Alert {
alerts = append(alerts, checkNodeNetwork(r, host)...)
alerts = append(alerts, checkNodeOlric(r, host)...)
alerts = append(alerts, checkNodeIPFS(r, host)...)
alerts = append(alerts, checkNodeVault(r, host)...)
alerts = append(alerts, checkNodeGateway(r, host)...)
}
@ -866,6 +867,41 @@ func checkNodeIPFS(r *report.NodeReport, host string) []Alert {
return alerts
}
func checkNodeVault(r *report.NodeReport, host string) []Alert {
if r.Vault == nil {
return nil
}
var alerts []Alert
if !r.Vault.ServiceActive {
alerts = append(alerts, Alert{AlertCritical, "vault", host, "Vault service not running"})
return alerts
}
if !r.Vault.Responsive {
alerts = append(alerts, Alert{AlertWarning, "vault", host, "Vault not responding to health queries"})
return alerts
}
switch r.Vault.Status {
case "unavailable":
alerts = append(alerts, Alert{AlertCritical, "vault", host,
fmt.Sprintf("Vault unavailable: %d/%d guardians healthy (need %d for reads)",
r.Vault.Healthy, r.Vault.Guardians, r.Vault.Threshold)})
case "degraded":
alerts = append(alerts, Alert{AlertWarning, "vault", host,
fmt.Sprintf("Vault degraded: %d/%d guardians healthy (need %d for writes)",
r.Vault.Healthy, r.Vault.Guardians, r.Vault.WriteQuorum)})
}
if r.Vault.RestartCount > 3 {
alerts = append(alerts, Alert{AlertWarning, "vault", host,
fmt.Sprintf("Vault restarted %d times", r.Vault.RestartCount)})
}
return alerts
}
func checkNodeGateway(r *report.NodeReport, host string) []Alert {
if r.Gateway == nil {
return nil

View File

@ -0,0 +1,120 @@
package monitor
import (
"testing"
"github.com/DeBrosOfficial/network/pkg/cli/production/report"
)
func TestCheckNodeVault_nil(t *testing.T) {
r := &report.NodeReport{}
alerts := checkNodeVault(r, "10.0.0.1")
if len(alerts) != 0 {
t.Errorf("expected 0 alerts for nil vault, got %d", len(alerts))
}
}
func TestCheckNodeVault_serviceInactive(t *testing.T) {
r := &report.NodeReport{
Vault: &report.VaultReport{ServiceActive: false},
}
alerts := checkNodeVault(r, "10.0.0.1")
if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts))
}
if alerts[0].Severity != AlertCritical {
t.Errorf("expected critical, got %s", alerts[0].Severity)
}
}
func TestCheckNodeVault_unresponsive(t *testing.T) {
r := &report.NodeReport{
Vault: &report.VaultReport{ServiceActive: true, Responsive: false},
}
alerts := checkNodeVault(r, "10.0.0.1")
if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts))
}
if alerts[0].Severity != AlertWarning {
t.Errorf("expected warning, got %s", alerts[0].Severity)
}
}
func TestCheckNodeVault_unavailable(t *testing.T) {
r := &report.NodeReport{
Vault: &report.VaultReport{
ServiceActive: true,
Responsive: true,
Status: "unavailable",
Guardians: 5,
Healthy: 1,
Threshold: 3,
WriteQuorum: 4,
},
}
alerts := checkNodeVault(r, "10.0.0.1")
if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts))
}
if alerts[0].Severity != AlertCritical {
t.Errorf("expected critical, got %s", alerts[0].Severity)
}
}
func TestCheckNodeVault_degraded(t *testing.T) {
r := &report.NodeReport{
Vault: &report.VaultReport{
ServiceActive: true,
Responsive: true,
Status: "degraded",
Guardians: 5,
Healthy: 3,
Threshold: 3,
WriteQuorum: 4,
},
}
alerts := checkNodeVault(r, "10.0.0.1")
if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts))
}
if alerts[0].Severity != AlertWarning {
t.Errorf("expected warning, got %s", alerts[0].Severity)
}
}
func TestCheckNodeVault_excessiveRestarts(t *testing.T) {
r := &report.NodeReport{
Vault: &report.VaultReport{
ServiceActive: true,
Responsive: true,
Status: "healthy",
RestartCount: 5,
},
}
alerts := checkNodeVault(r, "10.0.0.1")
if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts))
}
if alerts[0].Severity != AlertWarning {
t.Errorf("expected warning, got %s", alerts[0].Severity)
}
}
func TestCheckNodeVault_healthy(t *testing.T) {
r := &report.NodeReport{
Vault: &report.VaultReport{
ServiceActive: true,
Responsive: true,
Status: "healthy",
Guardians: 5,
Healthy: 5,
Threshold: 3,
WriteQuorum: 4,
RestartCount: 0,
},
}
alerts := checkNodeVault(r, "10.0.0.1")
if len(alerts) != 0 {
t.Errorf("expected 0 alerts for healthy vault, got %d", len(alerts))
}
}

View File

@ -0,0 +1,161 @@
// Package noderesolver provides unified node discovery for the orama CLI.
//
// It resolves operator-owned nodes by querying the network's gateway API
// (primary) or falling back to the legacy nodes.conf file.
package noderesolver
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/DeBrosOfficial/network/pkg/auth"
"github.com/DeBrosOfficial/network/pkg/cli"
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
"github.com/DeBrosOfficial/network/pkg/inspector"
)
// httpClient is the shared HTTP client for API calls.
var httpClient = &http.Client{Timeout: 10 * time.Second}
// ResolveNodes returns the operator's nodes for a given environment.
// It first tries the network API (GET /v1/operator/nodes), then falls
// back to nodes.conf if the API is unreachable or returns no results.
func ResolveNodes(env string) ([]inspector.Node, error) {
nodes, err := resolveFromNetwork(env)
if err == nil && len(nodes) > 0 {
return nodes, nil
}
// Fallback to nodes.conf
confNodes, confErr := remotessh.LoadEnvNodes(env)
if confErr != nil {
if err != nil {
return nil, fmt.Errorf("network API: %w; nodes.conf: %v", err, confErr)
}
return nil, confErr
}
return confNodes, nil
}
// ResolveNodesNetworkOnly queries only the network API without nodes.conf fallback.
func ResolveNodesNetworkOnly(env string) ([]inspector.Node, error) {
return resolveFromNetwork(env)
}
// resolveFromNetwork queries the gateway API for operator-owned nodes.
func resolveFromNetwork(env string) ([]inspector.Node, error) {
// 1. Get gateway URL for the environment
gatewayURL, err := gatewayURLForEnv(env)
if err != nil {
return nil, fmt.Errorf("failed to resolve gateway URL: %w", err)
}
// 2. Load stored credentials for this gateway
apiKey, err := loadAPIKey(gatewayURL)
if err != nil {
return nil, fmt.Errorf("no credentials for %s: %w (run 'orama auth login' first)", gatewayURL, err)
}
return resolveFromNetworkWithURL(gatewayURL, apiKey, env)
}
// resolveFromNetworkWithURL queries a specific gateway URL with an API key.
// Exported for testing.
func resolveFromNetworkWithURL(gatewayURL, apiKey, env string) ([]inspector.Node, error) {
endpoint := fmt.Sprintf("%s/v1/operator/nodes?env=%s", gatewayURL, url.QueryEscape(env))
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("X-API-Key", apiKey)
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to reach gateway: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gateway returned HTTP %d: %s", resp.StatusCode, string(body))
}
var result struct {
Nodes []struct {
ID string `json:"id"`
IPAddress string `json:"ip_address"`
InternalIP string `json:"internal_ip"`
Environment string `json:"environment"`
Role string `json:"role"`
SSHUser string `json:"ssh_user"`
Status string `json:"status"`
} `json:"nodes"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
nodes := make([]inspector.Node, 0, len(result.Nodes))
for _, n := range result.Nodes {
user := n.SSHUser
if user == "" {
user = "root"
}
// Sandbox nodes share a single SSH key; production nodes use per-host keys.
vaultTarget := fmt.Sprintf("%s/%s", n.IPAddress, user)
if n.Environment == "sandbox" {
vaultTarget = "sandbox/root"
}
nodes = append(nodes, inspector.Node{
Environment: n.Environment,
User: user,
Host: n.IPAddress,
Role: n.Role,
VaultTarget: vaultTarget,
})
}
return nodes, nil
}
// gatewayURLForEnv returns the gateway URL for a given environment name.
// If env is empty, uses the active environment.
func gatewayURLForEnv(env string) (string, error) {
if env == "" {
e, err := cli.GetActiveEnvironment()
if err != nil {
return "", err
}
return e.GatewayURL, nil
}
e, err := cli.GetEnvironmentByName(env)
if err != nil {
return "", err
}
return e.GatewayURL, nil
}
// loadAPIKey loads the stored API key for a gateway URL.
func loadAPIKey(gatewayURL string) (string, error) {
store, err := auth.LoadEnhancedCredentials()
if err != nil {
return "", fmt.Errorf("failed to load credentials: %w", err)
}
creds := store.GetDefaultCredential(gatewayURL)
if creds == nil || creds.APIKey == "" {
return "", fmt.Errorf("no credentials found for %s", gatewayURL)
}
return creds.APIKey, nil
}

View File

@ -0,0 +1,152 @@
package noderesolver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestGatewayURLForEnv_knownEnv(t *testing.T) {
url, err := gatewayURLForEnv("devnet")
if err != nil {
t.Fatalf("gatewayURLForEnv(devnet): %v", err)
}
if url == "" {
t.Error("expected non-empty gateway URL for devnet")
}
}
func TestGatewayURLForEnv_unknownEnv(t *testing.T) {
_, err := gatewayURLForEnv("nonexistent")
if err == nil {
t.Error("expected error for unknown environment")
}
}
func TestResolveFromMockServer_happyPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/operator/nodes" {
http.Error(w, "not found", http.StatusNotFound)
return
}
if r.Header.Get("X-API-Key") != "test-key" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
env := r.URL.Query().Get("env")
resp := map[string]interface{}{
"nodes": []map[string]string{
{"id": "node-1", "ip_address": "1.2.3.4", "environment": env, "role": "nameserver", "ssh_user": "root", "status": "active"},
{"id": "node-2", "ip_address": "5.6.7.8", "environment": env, "role": "node", "ssh_user": "ubuntu", "status": "active"},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
nodes, err := resolveFromNetworkWithURL(server.URL, "test-key", "devnet")
if err != nil {
t.Fatalf("resolveFromNetworkWithURL: %v", err)
}
if len(nodes) != 2 {
t.Fatalf("expected 2 nodes, got %d", len(nodes))
}
if nodes[0].Host != "1.2.3.4" {
t.Errorf("node 0 host = %q, want %q", nodes[0].Host, "1.2.3.4")
}
if nodes[0].Role != "nameserver" {
t.Errorf("node 0 role = %q, want %q", nodes[0].Role, "nameserver")
}
if nodes[0].VaultTarget != "1.2.3.4/root" {
t.Errorf("node 0 vault target = %q, want %q", nodes[0].VaultTarget, "1.2.3.4/root")
}
if nodes[0].Environment != "devnet" {
t.Errorf("node 0 environment = %q, want %q", nodes[0].Environment, "devnet")
}
if nodes[1].User != "ubuntu" {
t.Errorf("node 1 user = %q, want %q", nodes[1].User, "ubuntu")
}
if nodes[1].VaultTarget != "5.6.7.8/ubuntu" {
t.Errorf("node 1 vault target = %q, want %q", nodes[1].VaultTarget, "5.6.7.8/ubuntu")
}
}
func TestResolveFromMockServer_emptySSHUser(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{
"nodes": []map[string]string{
{"id": "node-1", "ip_address": "1.2.3.4", "environment": "devnet", "role": "node", "ssh_user": "", "status": "active"},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
nodes, err := resolveFromNetworkWithURL(server.URL, "key", "devnet")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
if nodes[0].User != "root" {
t.Errorf("user = %q, want %q (default)", nodes[0].User, "root")
}
if nodes[0].VaultTarget != "1.2.3.4/root" {
t.Errorf("vault target = %q, want %q", nodes[0].VaultTarget, "1.2.3.4/root")
}
}
func TestResolveFromMockServer_unauthorized(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
}))
defer server.Close()
_, err := resolveFromNetworkWithURL(server.URL, "bad-key", "devnet")
if err == nil {
t.Error("expected error for unauthorized request")
}
}
func TestResolveFromMockServer_emptyNodes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"nodes": []interface{}{}})
}))
defer server.Close()
nodes, err := resolveFromNetworkWithURL(server.URL, "key", "devnet")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(nodes) != 0 {
t.Errorf("expected 0 nodes, got %d", len(nodes))
}
}
func TestResolveFromMockServer_malformedJSON(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<html>not json</html>`))
}))
defer server.Close()
_, err := resolveFromNetworkWithURL(server.URL, "key", "devnet")
if err == nil {
t.Error("expected error for malformed JSON response")
}
}
func TestResolveFromMockServer_serverDown(t *testing.T) {
_, err := resolveFromNetworkWithURL("http://127.0.0.1:1", "key", "devnet")
if err == nil {
t.Error("expected error for unreachable server")
}
}

View File

@ -133,7 +133,7 @@ func cleanNode(node inspector.Node, nuclear bool) error {
%s
# Stop services
for svc in caddy coredns orama-node orama-gateway orama-ipfs-cluster orama-ipfs orama-olric orama-anyone-relay orama-anyone-client; do
for svc in caddy coredns orama-node orama-gateway orama-ipfs-cluster orama-ipfs orama-olric orama-vault orama-anyone-relay orama-anyone-client; do
systemctl stop "$svc" 2>/dev/null
systemctl disable "$svc" 2>/dev/null
done
@ -171,7 +171,7 @@ rm -f /tmp/orama-*.sh /tmp/network-source.tar.gz /tmp/orama-*.tar.gz
# Nuclear: remove binaries
if [ -n "$NUCLEAR" ]; then
rm -f /usr/local/bin/orama /usr/local/bin/orama-node /usr/local/bin/gateway
rm -f /usr/local/bin/identity /usr/local/bin/sfu /usr/local/bin/turn
rm -f /usr/local/bin/identity /usr/local/bin/sfu /usr/local/bin/turn /usr/local/bin/orama-sni-router
rm -f /usr/local/bin/olric-server /usr/local/bin/ipfs /usr/local/bin/ipfs-cluster-service
rm -f /usr/local/bin/rqlited /usr/local/bin/coredns
rm -f /usr/bin/caddy

View File

@ -43,6 +43,11 @@ type Flags struct {
AnyoneFamily string // Comma-separated fingerprints of other relays you operate
AnyoneBandwidth int // Percentage of VPS bandwidth for relay (default: 30, 0=unlimited)
AnyoneAccounting int // Monthly data cap for relay in GB (0=unlimited)
// Operator metadata (set by orama node setup, written to node.yaml for registration)
SSHUser string // SSH user for remote management
Environment string // Environment name (devnet, testnet, etc.)
OperatorWallet string // Operator wallet address
}
// ParseFlags parses install command flags
@ -90,6 +95,11 @@ func ParseFlags(args []string) (*Flags, error) {
fs.IntVar(&flags.AnyoneBandwidth, "anyone-bandwidth", 30, "Limit relay to N% of VPS bandwidth (0=unlimited, runs speedtest)")
fs.IntVar(&flags.AnyoneAccounting, "anyone-accounting", 0, "Monthly data cap for relay in GB (0=unlimited)")
// Operator metadata (set by orama node setup)
fs.StringVar(&flags.SSHUser, "ssh-user", "", "SSH user for remote management")
fs.StringVar(&flags.Environment, "environment", "", "Environment name (devnet, testnet, etc.)")
fs.StringVar(&flags.OperatorWallet, "operator-wallet", "", "Operator wallet address")
if err := fs.Parse(args); err != nil {
if err == flag.ErrHelp {
return nil, err

View File

@ -68,6 +68,11 @@ func NewOrchestrator(flags *Flags) (*Orchestrator, error) {
setup.SetAnyoneClient(true)
}
// Set operator metadata (from orama node setup)
setup.SSHUser = flags.SSHUser
setup.Environment = flags.Environment
setup.OperatorWallet = flags.OperatorWallet
validator := NewValidator(flags, oramaDir)
return &Orchestrator{

View File

@ -53,6 +53,7 @@ func HandleRestartWithFlags(force bool) {
{"orama-node"},
{"orama-olric"},
{"orama-ipfs-cluster", "orama-ipfs"},
{"orama-vault"},
{"orama-anyone-relay", "orama-anyone-client"},
{"coredns", "caddy"},
}

View File

@ -55,8 +55,9 @@ func HandleStopWithFlags(force bool) {
{"orama-node"}, // 1. Stop node (includes gateway + RQLite with leadership transfer)
{"orama-olric"}, // 2. Stop cache
{"orama-ipfs-cluster", "orama-ipfs"}, // 3. Stop storage
{"orama-anyone-relay", "orama-anyone-client"}, // 4. Stop privacy relay
{"coredns", "caddy"}, // 5. Stop DNS/TLS last
{"orama-vault"}, // 4. Stop vault
{"orama-anyone-relay", "orama-anyone-client"}, // 5. Stop privacy relay
{"coredns", "caddy"}, // 6. Stop DNS/TLS last
}
// Mask all services to immediately prevent Restart=always from reviving them.

View File

@ -89,6 +89,7 @@ func collectProcesses() *ProcessReport {
var managedServiceUnits = []string{
"orama-node", "orama-olric",
"orama-ipfs", "orama-ipfs-cluster",
"orama-vault",
"orama-anyone-relay", "orama-anyone-client",
"coredns", "caddy", "rqlited",
}

View File

@ -71,6 +71,10 @@ func Handle(jsonFlag bool, version string) error {
rpt.IPFS = collectIPFS()
})
safeGo(&wg, "vault", func() {
rpt.Vault = collectVault()
})
safeGo(&wg, "gateway", func() {
rpt.Gateway = collectGateway()
})

View File

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

View File

@ -17,6 +17,7 @@ type NodeReport struct {
RQLite *RQLiteReport `json:"rqlite,omitempty"`
Olric *OlricReport `json:"olric,omitempty"`
IPFS *IPFSReport `json:"ipfs,omitempty"`
Vault *VaultReport `json:"vault,omitempty"`
Gateway *GatewayReport `json:"gateway,omitempty"`
WireGuard *WireGuardReport `json:"wireguard,omitempty"`
DNS *DNSReport `json:"dns,omitempty"`
@ -150,6 +151,21 @@ type IPFSReport struct {
BootstrapEmpty bool `json:"bootstrap_empty"`
}
// --- Vault ---
type VaultReport struct {
ServiceActive bool `json:"service_active"`
Responsive bool `json:"responsive"`
Status string `json:"status,omitempty"` // "healthy", "degraded", "unavailable"
Guardians int `json:"guardians,omitempty"`
Healthy int `json:"healthy,omitempty"`
Threshold int `json:"threshold,omitempty"`
WriteQuorum int `json:"write_quorum,omitempty"`
ProcessMemMB int `json:"process_mem_mb"`
RestartCount int `json:"restart_count"`
LogErrors int `json:"log_errors_1h"`
}
// --- Gateway ---
type GatewayReport struct {

View File

@ -0,0 +1,70 @@
package report
import (
"context"
"encoding/json"
"strconv"
"strings"
"time"
)
func collectVault() *VaultReport {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
r := &VaultReport{}
// 1. Service active
if out, err := runCmd(ctx, "systemctl", "is-active", "orama-vault"); err == nil {
r.ServiceActive = strings.TrimSpace(out) == "active"
}
// 2. Restart count
if out, err := runCmd(ctx, "systemctl", "show", "orama-vault", "--property=NRestarts"); err == nil {
if parts := strings.SplitN(out, "=", 2); len(parts) == 2 {
r.RestartCount, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
}
}
// 3. Process memory
if out, err := runCmd(ctx, "systemctl", "show", "orama-vault", "--property=MemoryCurrent"); err == nil {
if parts := strings.SplitN(out, "=", 2); len(parts) == 2 {
r.ProcessMemMB = parseMemoryMB(parts[1])
}
}
// 4. Log errors in last hour
if out, err := runCmd(ctx, "bash", "-c",
`journalctl -u orama-vault --no-pager -n 200 --since "1 hour ago" 2>/dev/null | grep -ciE "(error|ERR)" || echo 0`); err == nil {
r.LogErrors, _ = strconv.Atoi(strings.TrimSpace(out))
}
// 5. Query vault status via gateway (provides guardian health)
if body, err := httpGet(ctx, "http://localhost:6001/v1/vault/status"); err == nil {
var status struct {
Guardians int `json:"guardians"`
Healthy int `json:"healthy"`
Threshold int `json:"threshold"`
WriteQuorum int `json:"write_quorum"`
}
if json.Unmarshal(body, &status) == nil {
r.Responsive = true
r.Guardians = status.Guardians
r.Healthy = status.Healthy
r.Threshold = status.Threshold
r.WriteQuorum = status.WriteQuorum
}
}
// 6. Query vault health status
if body, err := httpGet(ctx, "http://localhost:6001/v1/vault/health"); err == nil {
var health struct {
Status string `json:"status"`
}
if json.Unmarshal(body, &health) == nil {
r.Status = health.Status
}
}
return r
}

View File

@ -0,0 +1,369 @@
// Package setup implements the "orama node setup" command — a single command
// to bootstrap a fresh VPS into a running Orama node.
//
// Flow:
// 1. Create SSH key in rootwallet vault for this node
// 2. Install the public key on the VPS (one-time password-based SSH)
// 3. Upload the binary archive
// 4. For genesis: run install without --join
// 5. For joining: request invite token via operator API, run install with --join
package setup
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/DeBrosOfficial/network/pkg/auth"
"github.com/DeBrosOfficial/network/pkg/cli"
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
"github.com/DeBrosOfficial/network/pkg/inspector"
"github.com/DeBrosOfficial/network/pkg/rwagent"
)
// Options holds the flags for the setup command.
type Options struct {
IP string
Env string
Role string // "node" or "nameserver"
User string // SSH user (default: "root")
Password string // One-time password for initial SSH access
BaseDomain string
Gateway string // Gateway URL to use for invite tokens (overrides env config)
Genesis bool // If true, create a new cluster instead of joining
AnyoneRelay bool
}
// Run executes the node setup.
func Run(opts Options) error {
if opts.IP == "" {
return fmt.Errorf("--ip is required")
}
if opts.User == "" {
opts.User = "root"
}
if opts.Role == "" {
opts.Role = "node"
}
// 1. Ensure rootwallet agent is running
fmt.Println("Checking rootwallet agent...")
agentClient := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
status, err := agentClient.Status(ctx)
if err != nil {
return fmt.Errorf("rootwallet agent not reachable: %w (is the desktop app running?)", err)
}
if status.Locked {
return fmt.Errorf("rootwallet agent is locked — unlock it in the desktop app first")
}
// 2. Get operator wallet address
addrData, err := agentClient.GetAddress(ctx, "evm")
if err != nil {
return fmt.Errorf("failed to get wallet address: %w", err)
}
fmt.Printf(" Wallet: %s\n", addrData.Address)
// 3. Create SSH key in rootwallet vault for this node
vaultTarget := fmt.Sprintf("%s/%s", opts.IP, opts.User)
fmt.Printf(" Setting up SSH key for %s...\n", vaultTarget)
if err := remotessh.EnsureVaultEntry(vaultTarget); err != nil {
return fmt.Errorf("failed to create SSH key in vault: %w", err)
}
pubKey, err := remotessh.ResolveVaultPublicKey(vaultTarget)
if err != nil {
return fmt.Errorf("failed to get public key: %w", err)
}
// 4. Install the public key on the VPS via password SSH
if opts.Password != "" {
fmt.Printf(" Installing SSH key on %s...\n", opts.IP)
if err := installPublicKey(opts.IP, opts.User, opts.Password, pubKey); err != nil {
return fmt.Errorf("failed to install SSH key: %w", err)
}
fmt.Println(" SSH key installed")
} else {
fmt.Println(" No --password provided, assuming SSH key is already installed")
}
// 5. Test SSH with rootwallet key
fmt.Println(" Testing SSH connection...")
node := inspector.Node{
Host: opts.IP,
User: opts.User,
VaultTarget: vaultTarget,
Environment: opts.Env,
Role: opts.Role,
}
nodes := []inspector.Node{node}
cleanup, err := remotessh.PrepareNodeKeys(nodes)
if err != nil {
return fmt.Errorf("failed to prepare SSH key: %w", err)
}
defer cleanup()
node = nodes[0] // SSHKey is now set
testResult := inspector.RunSSH(context.Background(), node, "echo ok")
if !testResult.OK() {
return fmt.Errorf("SSH test failed: %s", testResult.Stderr)
}
fmt.Println(" SSH connection OK")
// 6. Check if binary archive needs uploading
if needsArchiveUpload(node) {
archivePath := findNewestArchive()
if archivePath == "" {
return fmt.Errorf("no binary archive found in /tmp/ (run `orama build` first)")
}
fmt.Printf(" Uploading archive (%s)...\n", filepath.Base(archivePath))
if err := remotessh.UploadFile(node, archivePath, "/tmp/archive.tar.gz"); err != nil {
return fmt.Errorf("failed to upload archive: %w", err)
}
extractCmd := "sudo bash -c 'mkdir -p /opt/orama && tar xzf /tmp/archive.tar.gz -C /opt/orama && rm -f /tmp/archive.tar.gz'"
if err := remotessh.RunSSHStreaming(node, extractCmd); err != nil {
return fmt.Errorf("failed to extract archive: %w", err)
}
fmt.Println(" Archive extracted")
} else {
fmt.Println(" Binary already present on node")
}
// 7. Build the install command
installCmd, err := buildInstallCommand(opts, node, agentClient)
if err != nil {
return fmt.Errorf("failed to build install command: %w", err)
}
fmt.Printf("\n Running: %s\n\n", installCmd)
// 8. Run the install
if err := remotessh.RunSSHStreaming(node, installCmd); err != nil {
return fmt.Errorf("install failed: %w", err)
}
// 9. After genesis install, update the environment gateway URL to this node's IP.
// This allows subsequent `node setup` calls to find the gateway automatically.
if opts.Genesis && opts.Env != "" {
gatewayURL := fmt.Sprintf("http://%s", opts.IP)
desc := fmt.Sprintf("%s (genesis: %s)", opts.Env, opts.IP)
if err := cli.AddEnvironment(opts.Env, gatewayURL, desc); err != nil {
fmt.Fprintf(os.Stderr, " Warning: failed to update environment: %v\n", err)
} else {
if err := cli.SwitchEnvironment(opts.Env); err != nil {
fmt.Fprintf(os.Stderr, " Warning: failed to switch environment: %v\n", err)
}
fmt.Printf(" Environment %q updated: gateway → %s\n", opts.Env, gatewayURL)
fmt.Printf("\n To join more nodes, first authenticate:\n")
fmt.Printf(" orama auth login\n")
fmt.Printf(" Then:\n")
fmt.Printf(" orama node setup --ip <IP> --password '<PASS>' --env %s --base-domain %s\n", opts.Env, opts.BaseDomain)
}
}
fmt.Printf("\n Node %s setup complete!\n", opts.IP)
return nil
}
// installPublicKey installs an SSH public key on a VPS using password authentication.
func installPublicKey(ip, user, password, pubKey string) error {
sshpassBin, err := findBinary("sshpass")
if err != nil {
return fmt.Errorf("sshpass is required for password-based SSH key installation: %w", err)
}
// Ensure .ssh directory exists and install the key
cmd := fmt.Sprintf(
`mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '%s' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && echo 'key installed'`,
strings.TrimSpace(pubKey),
)
args := []string{
"-p", password,
"ssh",
"-o", "StrictHostKeyChecking=no",
"-o", "ConnectTimeout=10",
"-o", "PreferredAuthentications=password",
"-o", "PubkeyAuthentication=no",
fmt.Sprintf("%s@%s", user, ip),
cmd,
}
out, err := runCommand(sshpassBin, args...)
if err != nil {
return fmt.Errorf("sshpass failed: %w (%s)", err, out)
}
if !strings.Contains(out, "key installed") {
return fmt.Errorf("unexpected output: %s", out)
}
return nil
}
// buildInstallCommand constructs the `sudo orama node install` command.
func buildInstallCommand(opts Options, node inspector.Node, agentClient *rwagent.Client) (string, error) {
parts := []string{"sudo /opt/orama/bin/orama node install"}
parts = append(parts, "--vps-ip", opts.IP)
if opts.BaseDomain != "" {
parts = append(parts, "--base-domain", opts.BaseDomain)
}
if strings.HasPrefix(opts.Role, "nameserver") {
parts = append(parts, "--nameserver")
if opts.BaseDomain != "" {
parts = append(parts, "--domain", opts.BaseDomain)
}
}
if opts.AnyoneRelay {
parts = append(parts, "--anyone-relay")
} else {
parts = append(parts, "--anyone-client")
}
// Pass operator metadata so the node registers with correct values
if opts.User != "" {
parts = append(parts, "--ssh-user", opts.User)
}
if opts.Env != "" {
parts = append(parts, "--environment", opts.Env)
}
// Get wallet address for operator tagging
ctx := context.Background()
if addrData, err := agentClient.GetAddress(ctx, "evm"); err == nil && addrData.Address != "" {
parts = append(parts, "--operator-wallet", addrData.Address)
}
if !opts.Genesis {
// Determine gateway URL for invite token request
gatewayURL := opts.Gateway
if gatewayURL == "" {
env := opts.Env
if env == "" {
active, err := cli.GetActiveEnvironment()
if err != nil {
return "", fmt.Errorf("failed to get active environment: %w", err)
}
env = active.Name
}
envConfig, err := cli.GetEnvironmentByName(env)
if err != nil {
return "", fmt.Errorf("environment %q not found (use --gateway to specify directly): %w", env, err)
}
gatewayURL = envConfig.GatewayURL
}
// Request invite token via operator API
token, err := requestInviteToken(gatewayURL)
if err != nil {
return "", fmt.Errorf("failed to get invite token: %w", err)
}
parts = append(parts, "--join", gatewayURL, "--token", token)
}
return strings.Join(parts, " "), nil
}
// requestInviteToken calls POST /v1/operator/invite to get an invite token.
func requestInviteToken(gatewayURL string) (string, error) {
store, err := auth.LoadEnhancedCredentials()
if err != nil {
return "", fmt.Errorf("failed to load credentials: %w", err)
}
creds := store.GetDefaultCredential(gatewayURL)
if creds == nil || creds.APIKey == "" {
return "", fmt.Errorf("no credentials for %s — run 'orama auth login' first", gatewayURL)
}
body, _ := json.Marshal(map[string]int{"expiry_minutes": 60})
req, err := http.NewRequest(http.MethodPost, gatewayURL+"/v1/operator/invite", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", creds.APIKey)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Token string `json:"token"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}
if result.Token == "" {
return "", fmt.Errorf("empty token in response")
}
return result.Token, nil
}
// needsArchiveUpload checks if the node already has the orama binary.
func needsArchiveUpload(node inspector.Node) bool {
result := inspector.RunSSH(context.Background(), node, "/opt/orama/bin/orama version 2>/dev/null")
return !result.OK()
}
// findNewestArchive finds the newest orama binary archive in /tmp/.
func findNewestArchive() string {
matches, _ := filepath.Glob("/tmp/orama-*-linux-*.tar.gz")
if len(matches) == 0 {
return ""
}
sort.Slice(matches, func(i, j int) bool {
fi, _ := os.Stat(matches[i])
fj, _ := os.Stat(matches[j])
if fi == nil || fj == nil {
return false
}
return fi.ModTime().After(fj.ModTime())
})
return matches[0]
}
func findBinary(name string) (string, error) {
paths := []string{
"/opt/homebrew/bin/" + name,
"/usr/local/bin/" + name,
"/usr/bin/" + name,
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return "", fmt.Errorf("%s not found", name)
}
func runCommand(bin string, args ...string) (string, error) {
cmd := &exec.Cmd{
Path: bin,
Args: append([]string{bin}, args...),
}
out, err := cmd.CombinedOutput()
return string(out), err
}

View File

@ -17,6 +17,7 @@ func Handle() {
"orama-ipfs-cluster",
// Note: RQLite is managed by node process, not as separate service
"orama-olric",
"orama-vault",
"orama-node",
// Note: gateway is embedded in orama-node, no separate service
}
@ -26,6 +27,7 @@ func Handle() {
"orama-ipfs": "IPFS Daemon",
"orama-ipfs-cluster": "IPFS Cluster",
"orama-olric": "Olric Cache Server",
"orama-vault": "Vault Guardian",
"orama-node": "Orama Node (includes RQLite + Gateway)",
}

View File

@ -376,6 +376,7 @@ func (o *Orchestrator) stopServices() error {
"orama-ipfs-cluster.service", // Depends on IPFS
"orama-ipfs.service", // Base IPFS
"orama-olric.service", // Independent
"orama-vault.service", // Vault guardian
"orama-anyone-client.service", // Client mode
"orama-anyone-relay.service", // Relay mode
}
@ -683,6 +684,7 @@ func (o *Orchestrator) restartServices() error {
"orama-olric", // Distributed cache
"orama-ipfs", // IPFS daemon
"orama-ipfs-cluster", // IPFS cluster
"orama-vault", // Vault guardian
"orama-gateway", // Gateway (legacy)
"coredns", // DNS server
"caddy", // Reverse proxy

View File

@ -42,7 +42,7 @@ func UploadFile(node inspector.Node, localPath, remotePath string, opts ...SSHOp
dest := fmt.Sprintf("%s@%s:%s", node.User, node.Host, remotePath)
args := []string{"-o", "ConnectTimeout=10", "-i", node.SSHKey}
args := []string{"-o", "ConnectTimeout=10", "-o", "IdentitiesOnly=yes", "-i", node.SSHKey}
if cfg.noHostKeyCheck {
args = append([]string{"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"}, args...)
} else {
@ -73,7 +73,7 @@ func RunSSHStreaming(node inspector.Node, command string, opts ...SSHOption) err
o(&cfg)
}
args := []string{"-o", "ConnectTimeout=10", "-i", node.SSHKey}
args := []string{"-o", "ConnectTimeout=10", "-o", "IdentitiesOnly=yes", "-i", node.SSHKey}
if cfg.noHostKeyCheck {
args = append([]string{"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"}, args...)
} else {

View File

@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/DeBrosOfficial/network/pkg/cli"
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
"github.com/DeBrosOfficial/network/pkg/inspector"
"github.com/DeBrosOfficial/network/pkg/rwagent"
@ -144,6 +145,18 @@ func Create(name string) error {
return fmt.Errorf("save final state: %w", err)
}
// Register sandbox as an environment and switch to it
gatewayURL := "https://" + cfg.Domain
desc := fmt.Sprintf("Sandbox cluster: %s (%s)", state.Name, cfg.Domain)
if err := cli.AddEnvironment("sandbox", gatewayURL, desc); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to register sandbox environment: %v\n", err)
} else if err := cli.SwitchEnvironment("sandbox"); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to switch to sandbox environment: %v\n", err)
}
// Tag all nodes with operator wallet for unified node management
registerNodesWithOperator(state, sshKeyPath)
printCreateSummary(cfg, state)
return nil
}
@ -633,6 +646,36 @@ func printCreateSummary(cfg *Config, state *SandboxState) {
fmt.Println("Destroy: orama sandbox destroy")
}
// registerNodesWithOperator tags all sandbox nodes with the operator's wallet
// via a direct RQLite UPDATE on the genesis node. This enables `orama nodes`
// to discover sandbox nodes alongside production nodes.
func registerNodesWithOperator(state *SandboxState, sshKeyPath string) {
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
addrData, err := client.GetAddress(ctx, "evm")
if err != nil || addrData == nil || addrData.Address == "" {
fmt.Fprintf(os.Stderr, "Warning: could not get operator wallet, nodes not tagged: %v\n", err)
return
}
wallet := addrData.Address
if len(state.Servers) == 0 {
return
}
genesis := state.Servers[0]
node := inspector.Node{User: "root", Host: genesis.IP, SSHKey: sshKeyPath}
// Use RQLite's parameterized query to avoid any injection risk.
// The JSON payload has the wallet as a parameter, not interpolated into SQL.
payload := fmt.Sprintf(`[["UPDATE dns_nodes SET operator_wallet = ?, environment = 'sandbox' WHERE operator_wallet IS NULL OR operator_wallet = ''", %q]]`, wallet)
cmd := fmt.Sprintf(`curl -sf -X POST http://localhost:5001/db/execute -H 'Content-Type: application/json' -d '%s'`, payload)
if _, err := runSSHOutput(node, cmd); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to tag nodes with operator wallet: %v\n", err)
}
}
// cleanupFailedCreate deletes any servers that were created during a failed provision.
func cleanupFailedCreate(client *HetznerClient, state *SandboxState) {
if len(state.Servers) == 0 {

View File

@ -4,8 +4,11 @@ import (
"bufio"
"fmt"
"os"
"os/exec"
"strings"
"sync"
"github.com/DeBrosOfficial/network/pkg/cli"
)
// Destroy tears down a sandbox cluster.
@ -100,10 +103,30 @@ func Destroy(name string, force bool) error {
return fmt.Errorf("delete state: %w", err)
}
// Remove sandbox environment entry, fall back to devnet
if err := cli.RemoveEnvironment("sandbox"); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to remove sandbox environment: %v\n", err)
}
// Clean up SSH known_hosts entries for destroyed server IPs.
// This prevents "REMOTE HOST IDENTIFICATION HAS CHANGED" errors
// when the same IPs are reused by a new sandbox.
cleanupKnownHosts(state)
fmt.Printf("\nSandbox %q destroyed (%d servers deleted)\n", state.Name, len(state.Servers))
return nil
}
// cleanupKnownHosts removes SSH known_hosts entries for all sandbox server IPs.
func cleanupKnownHosts(state *SandboxState) {
for _, srv := range state.Servers {
cmd := exec.Command("ssh-keygen", "-R", srv.IP)
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Run() // best-effort, ignore errors
}
}
// resolveSandbox finds a sandbox by name or returns the active one.
func resolveSandbox(name string) (*SandboxState, error) {
if name != "" {

View File

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

View File

@ -47,10 +47,29 @@ type DatabaseClient interface {
type PubSubClient interface {
Subscribe(ctx context.Context, topic string, handler MessageHandler) error
Publish(ctx context.Context, topic string, data []byte) error
// PublishBatch publishes multiple messages in parallel, one per topic.
// See pubsub.Manager.PublishBatch for semantics (fail-fast vs. best-effort).
PublishBatch(ctx context.Context, msgs []TopicMessage, opts PublishBatchOptions) error
// PublishSame sends the same payload to every topic in parallel.
PublishSame(ctx context.Context, topics []string, data []byte, opts PublishBatchOptions) error
Unsubscribe(ctx context.Context, topic string) error
ListTopics(ctx context.Context) ([]string, error)
}
// TopicMessage is one entry in a batch publish.
// Mirrors pubsub.TopicMessage to avoid forcing client callers to import pkg/pubsub.
type TopicMessage struct {
Topic string
Data []byte
}
// PublishBatchOptions controls batch publish behavior.
// Mirrors pubsub.PublishBatchOptions.
type PublishBatchOptions struct {
BestEffort bool
MaxConcurrency int
}
// NetworkInfo provides network status and peer information
type NetworkInfo interface {
GetPeers(ctx context.Context) ([]PeerInfo, error)

View File

@ -4,13 +4,13 @@ import (
"context"
"fmt"
"github.com/DeBrosOfficial/network/pkg/pubsub"
pkgpubsub "github.com/DeBrosOfficial/network/pkg/pubsub"
)
// pubSubBridge bridges between our PubSubClient interface and the pubsub package
type pubSubBridge struct {
client *Client
adapter *pubsub.ClientAdapter
adapter *pkgpubsub.ClientAdapter
}
func (p *pubSubBridge) Subscribe(ctx context.Context, topic string, handler MessageHandler) error {
@ -31,6 +31,26 @@ func (p *pubSubBridge) Publish(ctx context.Context, topic string, data []byte) e
return p.adapter.Publish(ctx, topic, data)
}
func (p *pubSubBridge) PublishBatch(ctx context.Context, msgs []TopicMessage, opts PublishBatchOptions) error {
if err := p.client.requireAccess(ctx); err != nil {
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
}
pkgMsgs := make([]pkgpubsub.TopicMessage, len(msgs))
for i, m := range msgs {
pkgMsgs[i] = pkgpubsub.TopicMessage{Topic: m.Topic, Data: m.Data}
}
pkgOpts := pkgpubsub.PublishBatchOptions{BestEffort: opts.BestEffort, MaxConcurrency: opts.MaxConcurrency}
return p.adapter.PublishBatch(ctx, pkgMsgs, pkgOpts)
}
func (p *pubSubBridge) PublishSame(ctx context.Context, topics []string, data []byte, opts PublishBatchOptions) error {
if err := p.client.requireAccess(ctx); err != nil {
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
}
pkgOpts := pkgpubsub.PublishBatchOptions{BestEffort: opts.BestEffort, MaxConcurrency: opts.MaxConcurrency}
return p.adapter.PublishSame(ctx, topics, data, pkgOpts)
}
func (p *pubSubBridge) Unsubscribe(ctx context.Context, topic string) error {
if err := p.client.requireAccess(ctx); err != nil {
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)

View File

@ -7,4 +7,7 @@ type NodeConfig struct {
DataDir string `yaml:"data_dir"` // Data directory
MaxConnections int `yaml:"max_connections"` // Maximum peer connections
Domain string `yaml:"domain"` // Domain for this node (e.g., node-1.orama.network)
SSHUser string `yaml:"ssh_user,omitempty"` // SSH user for remote management
Environment string `yaml:"environment,omitempty"` // Environment name (devnet, testnet, etc.)
OperatorWallet string `yaml:"operator_wallet,omitempty"` // Operator wallet address
}

View File

@ -181,6 +181,10 @@ func (m *mockHomeNodeDB) Tx(ctx context.Context, fn func(tx rqlite.Tx) error) er
return m.mockRQLiteClient.Tx(ctx, fn)
}
func (m *mockHomeNodeDB) Batch(ctx context.Context, ops []rqlite.BatchOp) (*rqlite.BatchResult, error) {
return m.mockRQLiteClient.Batch(ctx, ops)
}
func (m *mockHomeNodeDB) addDeployment(nodeID, deploymentID, status string) {
m.deployments[nodeID] = append(m.deployments[nodeID], deploymentData{
id: deploymentID,

View File

@ -149,6 +149,15 @@ func (m *mockRQLiteClient) Tx(ctx context.Context, fn func(tx rqlite.Tx) error)
return nil
}
func (m *mockRQLiteClient) Batch(ctx context.Context, ops []rqlite.BatchOp) (*rqlite.BatchResult, error) {
return &rqlite.BatchResult{Committed: true, Results: make([]rqlite.OpResult, len(ops))}, nil
}
func (m *mockRQLiteClient) BatchWithSeq(ctx context.Context, namespace string, ops []rqlite.BatchOp) (*rqlite.BatchResult, int64, error) {
res, err := m.Batch(ctx, ops)
return res, 1, err
}
func TestPortAllocator_AllocatePort(t *testing.T) {
logger := zap.NewNop()
mockDB := newMockRQLiteClient()

View File

@ -20,7 +20,10 @@ import (
// ConfigGenerator manages generation of node, gateway, and service configs
type ConfigGenerator struct {
oramaDir string
oramaDir string
SSHUser string // Operator metadata
Environment string
OperatorWallet string
}
// NewConfigGenerator creates a new config generator
@ -192,6 +195,11 @@ func (cg *ConfigGenerator) GenerateNodeConfig(peerAddresses []string, vpsIP stri
// HTTPS is still used for client-facing gateway traffic via autocert
// TLS can be enabled manually later if needed for inter-node encryption
// Operator metadata (set by orama node setup via --ssh-user, --environment, --operator-wallet)
data.SSHUser = cg.SSHUser
data.Environment = cg.Environment
data.OperatorWallet = cg.OperatorWallet
return templates.RenderNodeConfig(data)
}

View File

@ -390,7 +390,17 @@ func (ci *CaddyInstaller) generateCaddyfile(domain, email, acmeEndpoint, baseDom
sb.WriteString(fmt.Sprintf("\n%s {\n%s\n reverse_proxy localhost:6001\n}\n", baseDomain, tlsBlock))
}
// HTTP fallback (handles plain HTTP and ACME challenges)
// HTTP blocks — serve traffic over plain HTTP so the gateway is reachable
// even when TLS certificates are unavailable (e.g., Let's Encrypt rate limits).
// Without these, Caddy auto-redirects HTTP→HTTPS for the named domain blocks above.
sb.WriteString(fmt.Sprintf("\nhttp://*.%s {\n reverse_proxy localhost:6001\n}\n", domain))
sb.WriteString(fmt.Sprintf("\nhttp://%s {\n reverse_proxy localhost:6001\n}\n", domain))
if baseDomain != "" && baseDomain != domain {
sb.WriteString(fmt.Sprintf("\nhttp://*.%s {\n reverse_proxy localhost:6001\n}\n", baseDomain))
sb.WriteString(fmt.Sprintf("\nhttp://%s {\n reverse_proxy localhost:6001\n}\n", baseDomain))
}
// HTTP catch-all fallback (handles remaining plain HTTP traffic)
sb.WriteString("\n:80 {\n reverse_proxy localhost:6001\n}\n")
return sb.String()

View File

@ -53,6 +53,11 @@ type ProductionSetup struct {
serviceController *SystemdController
binaryInstaller *BinaryInstaller
NodePeerID string // Captured during Phase3 for later display
// Operator metadata (from --ssh-user, --environment, --operator-wallet flags)
SSHUser string
Environment string
OperatorWallet string
}
// ReadBranchPreference reads the stored branch preference from disk
@ -599,6 +604,11 @@ func (ps *ProductionSetup) Phase4GenerateConfigs(peerAddresses []string, vpsIP s
ps.logf("Phase 4: Generating configurations...")
}
// Propagate operator metadata to config generator
ps.configGenerator.SSHUser = ps.SSHUser
ps.configGenerator.Environment = ps.Environment
ps.configGenerator.OperatorWallet = ps.OperatorWallet
// Node config (unified architecture)
nodeConfig, err := ps.configGenerator.GenerateNodeConfig(peerAddresses, vpsIP, joinAddress, domain, baseDomain, enableHTTPS)
if err != nil {

View File

@ -86,31 +86,44 @@ func (fp *FilesystemProvisioner) EnsureDirectoryStructure() error {
// EnsureOramaUser creates the 'orama' system user and group for running services.
// Sets ownership of the orama data directory to the new user.
func (fp *FilesystemProvisioner) EnsureOramaUser() error {
// Check if user already exists
if err := exec.Command("id", "orama").Run(); err == nil {
return nil // user already exists
}
// Create system user with no login shell and home at /opt/orama
cmd := exec.Command("useradd", "--system", "--no-create-home",
"--home-dir", fp.oramaHome, "--shell", "/usr/sbin/nologin", "orama")
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create orama user: %w\n%s", err, string(output))
}
// Set ownership of orama directories
chown := exec.Command("chown", "-R", "orama:orama", fp.oramaDir)
if output, err := chown.CombinedOutput(); err != nil {
return fmt.Errorf("failed to chown %s: %w\n%s", fp.oramaDir, err, string(output))
}
// Also chown the bin directory
binDir := filepath.Join(fp.oramaHome, "bin")
if _, err := os.Stat(binDir); err == nil {
chown = exec.Command("chown", "-R", "orama:orama", binDir)
if output, err := chown.CombinedOutput(); err != nil {
return fmt.Errorf("failed to chown %s: %w\n%s", binDir, err, string(output))
// Check if user already exists; create if not
if err := exec.Command("id", "orama").Run(); err != nil {
cmd := exec.Command("useradd", "--system", "--no-create-home",
"--home-dir", fp.oramaHome, "--shell", "/usr/sbin/nologin", "orama")
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to create orama user: %w\n%s", err, string(output))
}
// Set ownership of orama directories (only on first create)
chown := exec.Command("chown", "-R", "orama:orama", fp.oramaDir)
if output, err := chown.CombinedOutput(); err != nil {
return fmt.Errorf("failed to chown %s: %w\n%s", fp.oramaDir, err, string(output))
}
binDir := filepath.Join(fp.oramaHome, "bin")
if _, err := os.Stat(binDir); err == nil {
chown = exec.Command("chown", "-R", "orama:orama", binDir)
if output, err := chown.CombinedOutput(); err != nil {
return fmt.Errorf("failed to chown %s: %w\n%s", binDir, err, string(output))
}
}
}
// Always ensure the sudoers rule is up-to-date (handles upgrades too).
// Resolve systemctl path to avoid hardcoding /bin vs /usr/bin.
systemctlPath, err := exec.LookPath("systemctl")
if err != nil {
systemctlPath = "/bin/systemctl" // fallback
}
// Grant orama user permission to manage namespace and deployment services.
sudoersRule := fmt.Sprintf(
"orama ALL=(root) NOPASSWD: %[1]s start orama-namespace-*, %[1]s stop orama-namespace-*, %[1]s enable orama-namespace-*, %[1]s disable orama-namespace-*, %[1]s restart orama-namespace-*, %[1]s start orama-deploy-*, %[1]s stop orama-deploy-*, %[1]s enable orama-deploy-*, %[1]s disable orama-deploy-*, %[1]s restart orama-deploy-*, %[1]s daemon-reload\n",
systemctlPath,
)
sudoersPath := "/etc/sudoers.d/orama-namespaces"
if err := os.WriteFile(sudoersPath, []byte(sudoersRule), 0440); err != nil {
return fmt.Errorf("failed to write sudoers rule: %w", err)
}
return nil

View File

@ -19,6 +19,18 @@ ProtectKernelTunables=yes
ProtectKernelModules=yes
RestrictNamespaces=yes`
// oramaNodeHardening is like oramaServiceHardening but WITHOUT NoNewPrivileges.
// The node process (which includes the gateway) needs to use sudo to manage
// namespace systemd services. NoNewPrivileges prevents sudo from working.
const oramaNodeHardening = `User=orama
Group=orama
ProtectSystem=strict
ProtectHome=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
RestrictNamespaces=yes`
// SystemdServiceGenerator generates systemd unit files
type SystemdServiceGenerator struct {
oramaHome string
@ -233,7 +245,7 @@ OOMScoreAdjust=-500
[Install]
WantedBy=multi-user.target
`, ssg.oramaHome, ssg.oramaDir, configFile, logFile, oramaServiceHardening)
`, ssg.oramaHome, ssg.oramaDir, configFile, logFile, oramaNodeHardening)
}
// GenerateVaultService generates the Orama Vault Guardian systemd unit.

View File

@ -5,6 +5,15 @@ node:
data_dir: "{{.DataDir}}"
max_connections: 50
domain: "{{.Domain}}"
{{- if .SSHUser}}
ssh_user: "{{.SSHUser}}"
{{- end}}
{{- if .Environment}}
environment: "{{.Environment}}"
{{- end}}
{{- if .OperatorWallet}}
operator_wallet: "{{.OperatorWallet}}"
{{- end}}
database:
data_dir: "{{.DataDir}}/rqlite"

View File

@ -41,6 +41,11 @@ type NodeConfigData struct {
NodeKey string // Path to X.509 private key for node-to-node communication
NodeCACert string // Path to CA certificate (optional)
NodeNoVerify bool // Skip certificate verification (for self-signed certs)
// Operator metadata — written to dns_nodes during registration
SSHUser string // SSH user for remote management
Environment string // Environment name (devnet, testnet, etc.)
OperatorWallet string // Operator wallet address
}
// GatewayConfigData holds parameters for gateway.yaml rendering

View File

@ -73,6 +73,10 @@ type JWTClaims struct {
Nbf int64 `json:"nbf"`
Exp int64 `json:"exp"`
Namespace string `json:"namespace"`
// Custom holds app-defined claims (e.g. tier, subscription state).
// Read by serverless functions via the get_caller_claim host call.
// May be nil if the token has no custom claims.
Custom map[string]string `json:"custom,omitempty"`
}
// ParseAndVerifyJWT verifies a JWT created by this gateway using kid-based key

View File

@ -416,3 +416,73 @@ func TestJWKSHandler_RSAOnly(t *testing.T) {
t.Errorf("expected RS256, got %s", result.Keys[0]["alg"])
}
}
// TestEdDSACrossServiceVerify is the regression test for bug #215. Two Service
// instances configured with the SAME Ed25519 key (the cluster-shared scenario
// produced by deterministic HKDF derivation in pkg/gateway/signing_key.go)
// must be able to verify each other's tokens. Without this guarantee a JWT
// minted on the main gateway is unverifiable on a namespace gateway and host
// functions see an empty caller_jwt_subject.
func TestEdDSACrossServiceVerify(t *testing.T) {
// Shared key — what HKDF would produce from the cluster secret.
_, shared, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate shared key: %v", err)
}
makeService := func() *Service {
s := createTestService(t) // RSA + mock client
s.SetEdDSAKey(shared)
return s
}
signer := makeService() // simulates main gateway
verifier := makeService() // simulates namespace gateway (different process, same shared key)
// Sanity: both services must agree on edKeyID since it is derived from the
// public key. If they don't, kid-based verification will silently fail.
if signer.edKeyID != verifier.edKeyID {
t.Fatalf("edKeyID mismatch: signer=%q verifier=%q", signer.edKeyID, verifier.edKeyID)
}
const wantSub = "BNbN2RNQTsYrrywZCLnhV9j3hd38jwcRqfxBecZX7hDE"
const wantNS = "anchat-test"
token, _, err := signer.GenerateJWT(wantNS, wantSub, 15*time.Minute)
if err != nil {
t.Fatalf("signer.GenerateJWT: %v", err)
}
claims, err := verifier.ParseAndVerifyJWT(token)
if err != nil {
t.Fatalf("cross-service verify failed: %v", err)
}
if claims.Sub != wantSub {
t.Errorf("Sub = %q, want %q", claims.Sub, wantSub)
}
if claims.Namespace != wantNS {
t.Errorf("Namespace = %q, want %q", claims.Namespace, wantNS)
}
}
// TestEdDSACrossServiceVerify_differentKeysFail proves the verify gate is
// real: when two services have different Ed25519 keys (the broken state
// before bug #215 fix), tokens minted on one MUST NOT validate on the other.
// If this test ever passes, the deterministic-derivation guarantee is
// silently bypassed somewhere.
func TestEdDSACrossServiceVerify_differentKeysFail(t *testing.T) {
signer := createTestService(t)
_, signKey, _ := ed25519.GenerateKey(rand.Reader)
signer.SetEdDSAKey(signKey)
verifier := createTestService(t)
_, verKey, _ := ed25519.GenerateKey(rand.Reader)
verifier.SetEdDSAKey(verKey)
token, _, err := signer.GenerateJWT("ns", "sub", 15*time.Minute)
if err != nil {
t.Fatalf("GenerateJWT: %v", err)
}
if _, err := verifier.ParseAndVerifyJWT(token); err == nil {
t.Fatal("expected verification to fail with different signing keys, got nil error")
}
}

View File

@ -56,4 +56,15 @@ type Config struct {
SFUPort int // Local SFU signaling port to proxy WebSocket connections to
TURNDomain string // TURN server domain for credential generation
TURNSecret string // HMAC-SHA1 shared secret for TURN credential generation
// StealthCDNDomain, when set, makes the WebRTC credentials handler
// advertise turns:<StealthCDNDomain>:443 (served by the SNI router).
StealthCDNDomain string
// Push notification configuration. Push is enabled when at least one
// provider URL/token is set. Tokens stored in the push_devices table
// are encrypted at rest via pkg/secrets using the cluster secret.
NtfyBaseURL string // ntfy server URL (e.g. "http://localhost:8080")
NtfyAuthToken string // optional bearer token for ntfy
ExpoAccessToken string // optional Expo access token
}

View File

@ -19,10 +19,15 @@ import (
"github.com/DeBrosOfficial/network/pkg/logging"
"github.com/DeBrosOfficial/network/pkg/olric"
"github.com/DeBrosOfficial/network/pkg/pubsub"
"github.com/DeBrosOfficial/network/pkg/push"
pushexpo "github.com/DeBrosOfficial/network/pkg/push/providers/expo"
pushntfy "github.com/DeBrosOfficial/network/pkg/push/providers/ntfy"
"github.com/DeBrosOfficial/network/pkg/rqlite"
"github.com/DeBrosOfficial/network/pkg/serverless"
"github.com/DeBrosOfficial/network/pkg/serverless/hostfunctions"
"github.com/DeBrosOfficial/network/pkg/serverless/persistent"
"github.com/DeBrosOfficial/network/pkg/serverless/triggers"
"github.com/DeBrosOfficial/network/pkg/serverless/wsbridge"
"github.com/multiformats/go-multiaddr"
olriclib "github.com/olric-data/olric"
"go.uber.org/zap"
@ -63,6 +68,34 @@ 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 (legacy single-tier; nil when push
// isn't configured at all). When PushManager is also set, send paths
// route through the manager instead so per-namespace config wins.
PushDispatcher *push.PushDispatcher
PushDeviceStore push.PushDeviceStore
// PushManager wraps the device store + per-namespace config store so
// tenants self-serve their push provider config via PUT /v1/push/config.
// Nil when push subsystem isn't initialized (cluster secret missing).
// When set, this is the canonical send path; PushDispatcher is the
// fallback used only if Manager is somehow missing.
PushManager *push.Manager
PushConfigStore push.ConfigStore
// Authentication service
AuthService *auth.Service
}
@ -140,11 +173,7 @@ func initializeRQLite(logger *logging.ColoredLogger, cfg *Config, deps *Dependen
// Inject basic auth credentials into DSN if available
dsn = injectRQLiteAuth(dsn, cfg.RQLiteUsername, cfg.RQLitePassword)
if strings.Contains(dsn, "?") {
dsn += "&disableClusterDiscovery=true&level=none"
} else {
dsn += "?disableClusterDiscovery=true&level=none"
}
dsn = appendRQLiteQueryParams(dsn)
db, err := sql.Open("rqlite", dsn)
if err != nil {
return fmt.Errorf("failed to open rqlite sql db: %w", err)
@ -157,7 +186,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)
@ -172,14 +211,32 @@ func initializeRQLite(logger *logging.ColoredLogger, cfg *Config, deps *Dependen
// Apply embedded migrations to ensure schema is up-to-date.
// This is critical for namespace gateways whose RQLite instances
// don't get migrations from the main cluster RQLiteManager.
//
// Failures here are FATAL: a gateway that can't bring its schema up
// to the version its binary expects will silently corrupt deploys
// later (e.g. INSERTing into missing columns and surfacing as a
// cryptic SQL error to end users). Better to refuse to start with
// a clear actionable error.
migCtx, migCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer migCancel()
if err := rqlite.ApplyEmbeddedMigrations(migCtx, db, migrations.FS, logger.Logger); err != nil {
logger.ComponentWarn(logging.ComponentGeneral, "Failed to apply embedded migrations to gateway RQLite",
zap.Error(err))
} else {
logger.ComponentInfo(logging.ComponentGeneral, "Embedded migrations applied to gateway RQLite")
return fmt.Errorf("apply embedded migrations failed: %w "+
"(hint: this gateway can't safely run without its required schema; "+
"check the underlying RQLite cluster health and re-run startup)", err)
}
logger.ComponentInfo(logging.ComponentGeneral, "Embedded migrations applied to gateway RQLite")
// Schema-version contract: even if the apply call returned nil, verify
// that the highest migration the binary embeds is recorded as applied.
// Catches:
// - silent partial-apply states where the marker row was never written
// - clusters where the binary was upgraded but RQLite has stale schema
// - operator manually deleted rows from schema_migrations
if err := migrations.AssertSchema(migCtx, db); err != nil {
return fmt.Errorf("schema contract violation: %w", err)
}
logger.ComponentInfo(logging.ComponentGeneral, "Schema contract satisfied",
zap.Int("required_version", migrations.RequiredVersion()))
return nil
}
@ -412,11 +469,39 @@ func initializeServerless(logger *logging.ColoredLogger, cfg *Config, deps *Depe
secretsMgr = smImpl
}
// Initialize push notification subsystem.
//
// Bug #220 follow-up: the subsystem now ALWAYS initializes when the
// cluster secret is available (so tenants can register devices and
// PUT their per-namespace push config), regardless of whether the
// gateway YAML has a default provider configured. The Manager wraps
// the device store + per-namespace ConfigStore; Send paths route
// through Manager so per-namespace config takes effect.
//
// PushDispatcher (legacy) is set only when YAML defaults exist —
// kept for back-compat with code that hasn't migrated to Manager.
pushDispatcher, pushStore, pushManager, pushCfgStore, 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
deps.PushManager = pushManager
deps.PushConfigStore = pushCfgStore
// 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 +509,21 @@ func initializeServerless(logger *logging.ColoredLogger, cfg *Config, deps *Depe
pubsubAdapter, // pubsub adapter for serverless functions
deps.ServerlessWSMgr,
secretsMgr,
pushDispatcher, // legacy — fallback when manager isn't wired
pushManager, // bug #220 follow-up — per-namespace config
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 +536,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 +555,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,
)
@ -477,8 +597,21 @@ func initializeServerless(logger *logging.ColoredLogger, cfg *Config, deps *Depe
return fmt.Errorf("failed to initialize auth service: %w", err)
}
// Load or create EdDSA key for new JWT tokens
edKey, err := loadOrCreateEdSigningKey(cfg.DataDir, logger)
// Load or create EdDSA key for new JWT tokens. Bug #215 fix: when
// cfg.ClusterSecret is set, the key is derived deterministically from
// it via HKDF, so every gateway in the cluster shares the same Ed25519
// keypair and JWTs verify cross-node. With an empty ClusterSecret the
// per-node legacy behaviour is retained (single-node test deployments).
if cfg.ClusterSecret == "" {
// Loud warning: a multi-node cluster booted without a cluster
// secret reproduces bug #215 (per-gateway random keys, JWTs
// unverifiable cross-node). Single-node test rigs are the only
// legitimate case.
logger.ComponentWarn(logging.ComponentGeneral,
"ClusterSecret is empty; JWT signing keys will be random per-node. "+
"Multi-node clusters MUST set ClusterSecret or JWTs will not verify across gateways (bug #215).")
}
edKey, err := loadOrCreateEdSigningKey(cfg.DataDir, cfg.ClusterSecret, logger)
if err != nil {
logger.ComponentWarn(logging.ComponentGeneral, "Failed to load EdDSA signing key; new JWTs will use RS256",
zap.Error(err))
@ -686,3 +819,119 @@ func injectRQLiteAuth(dsn, username, password string) string {
}
return dsn
}
// appendRQLiteQueryParams adds the standard query parameters to a RQLite DSN:
//
// - `disableClusterDiscovery=true` — gorqlite's discovery /nodes call is
// unreliable when peers are unreachable; we manage topology ourselves.
// - `level=weak` — Bug #235. Reads route to the leader (the only node
// guaranteed to have all committed writes), so a SELECT after an UPDATE
// in the same serverless invocation sees the new state. Previously
// `level=none`, which read from the local follower's possibly-stale
// snapshot. gorqlite's upstream default is `weak`; we were overriding
// to `none` and that hid this bug.
//
// The cost of `weak` over `none` is one HTTP hop to the leader (~1-2ms over
// the WireGuard mesh) and applies only to reads. Writes are unaffected
// because rqlite always redirects them to the leader regardless of `level`.
func appendRQLiteQueryParams(dsn string) string {
const params = "disableClusterDiscovery=true&level=weak"
if strings.Contains(dsn, "?") {
return dsn + "&" + params
}
return dsn + "?" + params
}
// buildPushDispatcher constructs the push subsystem.
//
// As of bug #220 follow-up, push always initializes when ClusterSecret is
// available, regardless of whether any YAML provider config is set:
//
// - Device store + ConfigStore always build (tenants need to register
// devices and set per-namespace push config even on gateways with no
// YAML defaults).
// - Manager wraps the stores + a YAML-derived Defaults fallback. Each
// namespace can override any default via PUT /v1/push/config.
// - The legacy single-tier dispatcher is built only when YAML defaults
// are non-empty — kept for back-compat with code paths that haven't
// migrated to Manager.
//
// Returns (nil, nil, nil, nil, nil) when ClusterSecret is missing
// (push subsystem disabled — credentials can't be encrypted safely).
// Returns hard error only on store-init failure.
func buildPushDispatcher(
cfg *Config,
db rqlite.Client,
logger *logging.ColoredLogger,
) (*push.PushDispatcher, push.PushDeviceStore, *push.Manager, push.ConfigStore, error) {
if cfg.ClusterSecret == "" {
// Without the cluster secret we can't encrypt credentials at rest.
// Disable the whole push subsystem; HTTP routes return 503.
return nil, nil, nil, nil, nil
}
store, err := push.NewRqliteDeviceStore(db, cfg.ClusterSecret, logger.Logger)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("init push device store: %w", err)
}
cfgStore, err := push.NewRqliteConfigStore(db, cfg.ClusterSecret, logger.Logger)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("init push config store: %w", err)
}
// ProviderFactory turns a resolved Config into the right set of
// provider instances. Lives here in dependencies.go because this is
// the only place that imports both the manager package and the
// concrete provider sub-packages — keeps push core dep-cycle-free.
factory := func(c push.Config) []push.PushProvider {
var ps []push.PushProvider
if c.NtfyBaseURL != "" {
ps = append(ps, pushntfy.New(pushntfy.Config{
BaseURL: c.NtfyBaseURL,
AuthToken: c.NtfyAuthToken,
}, logger.Logger))
}
if c.ExpoAccessToken != "" {
ps = append(ps, pushexpo.New(pushexpo.Config{
AccessToken: c.ExpoAccessToken,
}, logger.Logger))
}
return ps
}
defaults := push.Defaults{
NtfyBaseURL: cfg.NtfyBaseURL,
NtfyAuthToken: cfg.NtfyAuthToken,
ExpoAccessToken: cfg.ExpoAccessToken,
}
manager := push.NewManager(store, cfgStore, defaults, factory, logger.Logger)
// Legacy single-tier dispatcher kept ONLY when YAML defaults exist —
// some non-Manager code paths (notably the WASM push_send hostfunc
// before its migration to Manager) still expect a populated
// PushDispatcher. New code routes via Manager.
var legacy *push.PushDispatcher
if !defaults.IsEmpty() {
legacy = push.New(store, logger.Logger)
for _, p := range factory(push.Config{
NtfyBaseURL: defaults.NtfyBaseURL,
NtfyAuthToken: defaults.NtfyAuthToken,
ExpoAccessToken: defaults.ExpoAccessToken,
}) {
legacy.Register(p)
}
}
if defaults.NtfyBaseURL != "" {
logger.ComponentInfo(logging.ComponentGeneral, "push default provider: ntfy",
zap.String("base_url", defaults.NtfyBaseURL))
}
if defaults.ExpoAccessToken != "" {
logger.ComponentInfo(logging.ComponentGeneral, "push default provider: expo configured")
}
logger.ComponentInfo(logging.ComponentGeneral,
"push subsystem initialized; tenants can self-serve via PUT /v1/push/config")
return legacy, store, manager, cfgStore, nil
}

View File

@ -0,0 +1,59 @@
package gateway
import (
"strings"
"testing"
)
// TestAppendRQLiteQueryParams_consistencyLevelWeak is the regression guard
// for bug #235. The DSN passed to gorqlite MUST encode `level=weak` so reads
// route to the leader and see all committed writes from earlier in the same
// serverless invocation. `level=none` (the previous default) read from the
// local follower's possibly-stale state and broke `INSERT → UPDATE → SELECT`
// patterns inside host functions.
func TestAppendRQLiteQueryParams_consistencyLevelWeak(t *testing.T) {
got := appendRQLiteQueryParams("http://localhost:5001")
if !strings.Contains(got, "level=weak") {
t.Errorf("DSN missing level=weak (bug #235 regression):\n%s", got)
}
if strings.Contains(got, "level=none") {
t.Errorf("DSN must NOT carry level=none (bug #235):\n%s", got)
}
if !strings.Contains(got, "disableClusterDiscovery=true") {
t.Errorf("DSN missing disableClusterDiscovery=true:\n%s", got)
}
}
// TestAppendRQLiteQueryParams_existingQueryString — when the inbound DSN
// already has a `?param=value` segment (e.g. authentication appended
// upstream), the new params must be `&`-joined, not start a fresh `?`.
func TestAppendRQLiteQueryParams_existingQueryString(t *testing.T) {
got := appendRQLiteQueryParams("http://localhost:5001?foo=bar")
if strings.Count(got, "?") != 1 {
t.Errorf("expected exactly one '?' in DSN, got: %s", got)
}
if !strings.Contains(got, "?foo=bar&disableClusterDiscovery=true&level=weak") {
t.Errorf("DSN didn't append params with '&' join:\n%s", got)
}
}
// TestAppendRQLiteQueryParams_noExistingQueryString — when no `?` is present,
// the params must be introduced with a `?` not an `&`.
func TestAppendRQLiteQueryParams_noExistingQueryString(t *testing.T) {
got := appendRQLiteQueryParams("http://localhost:5001")
if !strings.HasSuffix(got, "?disableClusterDiscovery=true&level=weak") {
t.Errorf("DSN didn't introduce query string with '?':\n%s", got)
}
}
// TestAppendRQLiteQueryParams_preservesAuthCredentials — credentials injected
// upstream by injectRQLiteAuth must survive the param append unchanged.
func TestAppendRQLiteQueryParams_preservesAuthCredentials(t *testing.T) {
got := appendRQLiteQueryParams("http://orama:secret@localhost:5001")
if !strings.Contains(got, "orama:secret@localhost:5001") {
t.Errorf("auth credentials lost:\n%s", got)
}
if !strings.Contains(got, "level=weak") {
t.Errorf("level=weak missing after auth-injected DSN:\n%s", got)
}
}

View File

@ -28,10 +28,12 @@ import (
"github.com/DeBrosOfficial/network/pkg/gateway/handlers/cache"
deploymentshandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/deployments"
pubsubhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/pubsub"
pushhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/push"
serverlesshandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/serverless"
enrollhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/enroll"
joinhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/join"
webrtchandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/webrtc"
operatorhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/operator"
vaulthandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/vault"
wireguardhandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/wireguard"
sqlitehandlers "github.com/DeBrosOfficial/network/pkg/gateway/handlers/sqlite"
@ -42,6 +44,8 @@ import (
"github.com/DeBrosOfficial/network/pkg/olric"
"github.com/DeBrosOfficial/network/pkg/rqlite"
"github.com/DeBrosOfficial/network/pkg/serverless"
"github.com/DeBrosOfficial/network/pkg/serverless/persistent"
"github.com/DeBrosOfficial/network/pkg/serverless/triggers"
_ "github.com/mattn/go-sqlite3"
"go.uber.org/zap"
)
@ -82,13 +86,17 @@ type Gateway struct {
mu sync.RWMutex
presenceMu sync.RWMutex
pubsubHandlers *pubsubhandlers.PubSubHandlers
pushHandlers *pushhandlers.Handlers
// Serverless function engine
serverlessEngine *serverless.Engine
serverlessRegistry *serverless.Registry
serverlessInvoker *serverless.Invoker
serverlessWSMgr *serverless.WSManager
serverlessHandlers *serverlesshandlers.ServerlessHandlers
serverlessEngine *serverless.Engine
serverlessRegistry *serverless.Registry
serverlessInvoker *serverless.Invoker
serverlessWSMgr *serverless.WSManager
serverlessHandlers *serverlesshandlers.ServerlessHandlers
pubsubDispatcher *triggers.PubSubDispatcher
persistentWSManager *persistent.Manager
cronScheduler *triggers.CronScheduler
// Authentication service
authService *auth.Service
@ -168,7 +176,8 @@ type Gateway struct {
proxyTransport *http.Transport
// Vault proxy handlers
vaultHandlers *vaulthandlers.Handlers
vaultHandlers *vaulthandlers.Handlers
operatorHandler *operatorhandlers.Handler
// Namespace health state (local service probes + hourly reconciliation)
nsHealth *namespaceHealthState
@ -340,10 +349,39 @@ 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.
//
// Prefer the Manager-backed constructor (bug #220 follow-up) so
// tenants can self-serve their push config via PUT /v1/push/config.
// Fall back to the legacy constructor when only the YAML-derived
// dispatcher is available (older deployments without ClusterSecret).
if deps.PushManager != nil {
gw.pushHandlers = pushhandlers.NewHandlersWithManager(
deps.PushManager,
deps.PushConfigStore,
deps.PushDeviceStore,
logger,
)
} else 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 +443,7 @@ func New(logger *logging.ColoredLogger, cfg *Config) (*Gateway, error) {
gw.joinHandler = joinhandlers.NewHandler(logger.Logger, deps.ORMClient, cfg.DataDir)
gw.enrollHandler = enrollhandlers.NewHandler(logger.Logger, deps.ORMClient, cfg.DataDir)
gw.vaultHandlers = vaulthandlers.NewHandlers(logger, deps.Client)
gw.operatorHandler = operatorhandlers.NewHandler(logger.Logger, deps.ORMClient)
}
// Initialize deployment system

View File

@ -162,6 +162,15 @@ func (m *mockRQLiteClient) Tx(ctx context.Context, fn func(tx rqlite.Tx) error)
return nil
}
func (m *mockRQLiteClient) Batch(ctx context.Context, ops []rqlite.BatchOp) (*rqlite.BatchResult, error) {
return &rqlite.BatchResult{Committed: true, Results: make([]rqlite.OpResult, len(ops))}, nil
}
func (m *mockRQLiteClient) BatchWithSeq(ctx context.Context, namespace string, ops []rqlite.BatchOp) (*rqlite.BatchResult, int64, error) {
res, err := m.Batch(ctx, ops)
return res, 1, err
}
// mockProcessManager implements a mock process manager for testing
type mockProcessManager struct {
StartFunc func(ctx context.Context, deployment *deployments.Deployment, workDir string) error

View File

@ -129,6 +129,9 @@ func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) {
return
}
// 1b. Look up the operator wallet from the consumed token (may be empty for legacy tokens)
operatorWallet := h.tokenOperatorWallet(ctx, req.Token)
// 2. Clean up stale WG entries for this public IP (from previous installs).
// This prevents ghost peers: old rows with different node_id/wg_key that
// the sync loop would keep trying to reach.
@ -150,8 +153,8 @@ func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) {
// 4. Register WG peer in database
nodeID := fmt.Sprintf("node-%s", wgIP) // temporary ID based on WG IP
_, err = h.rqliteClient.Exec(ctx,
"INSERT OR REPLACE INTO wireguard_peers (node_id, wg_ip, public_key, public_ip, wg_port) VALUES (?, ?, ?, ?, ?)",
nodeID, wgIP, req.WGPublicKey, req.PublicIP, 51820)
"INSERT OR REPLACE INTO wireguard_peers (node_id, wg_ip, public_key, public_ip, wg_port, operator_wallet) VALUES (?, ?, ?, ?, ?, ?)",
nodeID, wgIP, req.WGPublicKey, req.PublicIP, 51820, operatorWallet)
if err != nil {
h.logger.Error("failed to register WG peer", zap.Error(err))
http.Error(w, "failed to register peer", http.StatusInternalServerError)
@ -307,6 +310,22 @@ func (h *Handler) consumeToken(ctx context.Context, token, usedByIP string) erro
return nil
}
// tokenOperatorWallet looks up the operator_wallet from a consumed invite token.
// Returns empty string if the token has no operator (legacy tokens).
func (h *Handler) tokenOperatorWallet(ctx context.Context, token string) string {
var rows []struct {
Wallet string `db:"operator_wallet"`
}
if err := h.rqliteClient.Query(ctx, &rows,
"SELECT COALESCE(operator_wallet, '') AS operator_wallet FROM invite_tokens WHERE token = ?", token); err != nil {
return ""
}
if len(rows) > 0 {
return rows[0].Wallet
}
return ""
}
// assignWGIP finds the next available 10.0.0.x IP by querying all peers and
// finding the numerically highest IP. This avoids lexicographic comparison issues
// where MAX("10.0.0.9") > MAX("10.0.0.10") in SQL string comparison.

View File

@ -0,0 +1,99 @@
// Package operator provides HTTP handlers for node operator management.
//
// Operators authenticate via wallet JWT (same auth flow as namespaces).
// Each operator's nodes are tracked by their wallet address in the
// dns_nodes and wireguard_peers tables.
package operator
import (
"context"
"encoding/json"
"net/http"
"strings"
"github.com/DeBrosOfficial/network/pkg/gateway/auth"
"github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys"
"github.com/DeBrosOfficial/network/pkg/rqlite"
"go.uber.org/zap"
)
// Handler provides HTTP handlers for operator node management.
type Handler struct {
logger *zap.Logger
rqliteClient rqlite.Client
}
// NewHandler creates an operator handler.
func NewHandler(logger *zap.Logger, rqliteClient rqlite.Client) *Handler {
return &Handler{
logger: logger,
rqliteClient: rqliteClient,
}
}
// walletFromRequest extracts the operator's wallet address from the request.
// Supports both JWT auth (wallet in Sub claim) and API key auth (wallet looked
// up from wallet_api_keys table).
func (h *Handler) walletFromRequest(r *http.Request) string {
// 1. Try JWT claims first (wallet JWT auth sets Sub = "0x...")
if claims, ok := r.Context().Value(ctxkeys.JWT).(*auth.JWTClaims); ok && claims != nil {
sub := strings.TrimSpace(claims.Sub)
if strings.HasPrefix(strings.ToLower(sub), "0x") {
return sub
}
// JWT with API key subject
if strings.HasPrefix(strings.ToLower(sub), "ak_") {
return h.resolveWalletFromAPIKey(r.Context(), sub)
}
}
// 2. Try API key from context (X-API-Key header, no JWT)
if apiKey, ok := r.Context().Value(ctxkeys.APIKey).(string); ok && apiKey != "" {
return h.resolveWalletFromAPIKey(r.Context(), apiKey)
}
return ""
}
// resolveWalletFromAPIKey looks up the wallet address linked to an API key.
// It queries namespace_ownership for a wallet-type owner of the namespace.
func (h *Handler) resolveWalletFromAPIKey(ctx context.Context, apiKeySub string) string {
if h.rqliteClient == nil {
return ""
}
ns := extractNamespace(apiKeySub)
if ns == "" {
return ""
}
var rows []struct {
OwnerID string `db:"owner_id"`
}
if err := h.rqliteClient.Query(ctx, &rows,
`SELECT no.owner_id FROM namespace_ownership no
JOIN namespaces n ON no.namespace_id = n.id
WHERE n.name = ? AND no.owner_type = 'wallet'
LIMIT 1`,
ns); err != nil || len(rows) == 0 {
return ""
}
return rows[0].OwnerID
}
// extractNamespace extracts the namespace from an API key subject like "ak_xxx:namespace".
func extractNamespace(apiKeySub string) string {
parts := strings.SplitN(apiKeySub, ":", 2)
if len(parts) == 2 {
return parts[1]
}
return apiKeySub
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}

View File

@ -0,0 +1,242 @@
package operator
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/DeBrosOfficial/network/pkg/gateway/auth"
"github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys"
)
func TestWalletFromRequest_withClaims(t *testing.T) {
h := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodGet, "/", nil)
claims := &auth.JWTClaims{Sub: "0xabc123"}
ctx := context.WithValue(r.Context(), ctxkeys.JWT, claims)
r = r.WithContext(ctx)
wallet := h.walletFromRequest(r)
if wallet != "0xabc123" {
t.Errorf("wallet = %q, want %q", wallet, "0xabc123")
}
}
func TestWalletFromRequest_noClaims(t *testing.T) {
h := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodGet, "/", nil)
wallet := h.walletFromRequest(r)
if wallet != "" {
t.Errorf("wallet = %q, want empty", wallet)
}
}
func TestWalletFromRequest_nilClaims(t *testing.T) {
h := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodGet, "/", nil)
ctx := context.WithValue(r.Context(), ctxkeys.JWT, (*auth.JWTClaims)(nil))
r = r.WithContext(ctx)
wallet := h.walletFromRequest(r)
if wallet != "" {
t.Errorf("wallet = %q, want empty", wallet)
}
}
func TestWalletFromRequest_apiKeyContext(t *testing.T) {
// When auth middleware sets ctxkeys.APIKey (no JWT), walletFromRequest
// should try to resolve via the API key. With nil rqliteClient it returns
// empty (can't query DB), but it shouldn't panic.
h := NewHandler(nil, nil)
r := httptest.NewRequest(http.MethodGet, "/", nil)
ctx := context.WithValue(r.Context(), ctxkeys.APIKey, "ak_test:myns")
r = r.WithContext(ctx)
// Should not panic — returns empty because no DB to query
wallet := h.walletFromRequest(r)
if wallet != "" {
t.Errorf("wallet = %q, want empty (no DB)", wallet)
}
}
func TestExtractNamespace(t *testing.T) {
tests := []struct {
input string
want string
}{
{"ak_abc123:myns", "myns"},
{"ak_abc123", "ak_abc123"},
{"", ""},
}
for _, tt := range tests {
got := extractNamespace(tt.input)
if got != tt.want {
t.Errorf("extractNamespace(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestDecodeJSON_valid(t *testing.T) {
body := strings.NewReader(`{"node_id":"test-node","environment":"devnet"}`)
r := httptest.NewRequest(http.MethodPost, "/", body)
var req RegisterRequest
if err := decodeJSON(r, &req); err != nil {
t.Fatalf("decodeJSON: %v", err)
}
if req.NodeID != "test-node" {
t.Errorf("NodeID = %q, want %q", req.NodeID, "test-node")
}
if req.Environment != "devnet" {
t.Errorf("Environment = %q, want %q", req.Environment, "devnet")
}
}
func TestDecodeJSON_invalid(t *testing.T) {
body := strings.NewReader(`not-json`)
r := httptest.NewRequest(http.MethodPost, "/", body)
var req RegisterRequest
if err := decodeJSON(r, &req); err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestHandleInvite_noAuth(t *testing.T) {
h := NewHandler(nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/v1/operator/invite", nil)
h.HandleInvite(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestHandleInvite_wrongMethod(t *testing.T) {
h := NewHandler(nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/v1/operator/invite", nil)
h.HandleInvite(w, r)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
}
func TestHandleListNodes_noAuth(t *testing.T) {
h := NewHandler(nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/v1/operator/nodes", nil)
h.HandleListNodes(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestHandleListNodes_wrongMethod(t *testing.T) {
h := NewHandler(nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/v1/operator/nodes", nil)
h.HandleListNodes(w, r)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
}
func TestHandleRegister_noAuth(t *testing.T) {
h := NewHandler(nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register", strings.NewReader(`{"node_id":"test"}`))
h.HandleRegister(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestHandleRegister_missingFields(t *testing.T) {
h := NewHandler(nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register", strings.NewReader(`{}`))
claims := &auth.JWTClaims{Sub: "0xabc"}
r = r.WithContext(context.WithValue(r.Context(), ctxkeys.JWT, claims))
h.HandleRegister(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestHandleRegister_invalidEnvironment(t *testing.T) {
h := NewHandler(nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register",
strings.NewReader(`{"node_id":"test","environment":"<script>alert(1)</script>"}`))
claims := &auth.JWTClaims{Sub: "0xabc"}
r = r.WithContext(context.WithValue(r.Context(), ctxkeys.JWT, claims))
h.HandleRegister(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestHandleRegister_invalidRole(t *testing.T) {
h := NewHandler(nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register",
strings.NewReader(`{"node_id":"test","role":"admin"}`))
claims := &auth.JWTClaims{Sub: "0xabc"}
r = r.WithContext(context.WithValue(r.Context(), ctxkeys.JWT, claims))
h.HandleRegister(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestAllowedEnvironments(t *testing.T) {
valid := []string{"devnet", "testnet", "sandbox", "production", "mainnet"}
invalid := []string{"staging", "local", "<script>", ""}
for _, env := range valid {
if !allowedEnvironments[env] {
t.Errorf("expected %q to be allowed", env)
}
}
for _, env := range invalid {
if allowedEnvironments[env] {
t.Errorf("expected %q to be disallowed", env)
}
}
}
func TestAllowedRoles(t *testing.T) {
valid := []string{"node", "nameserver", "nameserver-ns1", "nameserver-ns2", "nameserver-ns3"}
invalid := []string{"admin", "root", ""}
for _, role := range valid {
if !allowedRoles[role] {
t.Errorf("expected %q to be allowed", role)
}
}
for _, role := range invalid {
if allowedRoles[role] {
t.Errorf("expected %q to be disallowed", role)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,233 @@
package push
// config_handler.go — tenant-self-service push provider configuration.
//
// Endpoints (mounted under /v1/push/config; namespace-ownership middleware applies):
//
// GET /v1/push/config → current config (secrets redacted: only "has_X" booleans)
// PUT /v1/push/config → set/update fields; sensitive credentials encrypted at rest
// DELETE /v1/push/config → clear the namespace's row (push reverts to gateway YAML defaults)
//
// Bug #220 follow-up. Eliminates the "tenant must file an ops ticket"
// workflow: once this lands, AnChat (and every future tenant) self-serves
// their push provider config via authenticated HTTP, no operator
// involvement.
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/DeBrosOfficial/network/pkg/push"
"go.uber.org/zap"
)
// configManager is the subset of *push.Manager the config handlers need —
// kept narrow for testability.
type configManager interface {
IsConfigured(ctx contextLike, namespace string) bool
Invalidate(namespace string)
}
// contextLike avoids importing context everywhere — the handler is
// already in package serverless which has request contexts.
type contextLike = interface {
Done() <-chan struct{}
}
// PutConfigRequest is the body of PUT /v1/push/config.
//
// Field semantics:
// - Unset fields (zero value) leave the existing value alone.
// - Empty-string fields explicitly clear the value.
// - To clear the entire row use DELETE — that's clearer than empty PUT.
type PutConfigRequest struct {
NtfyBaseURL *string `json:"ntfy_base_url,omitempty"`
NtfyAuthToken *string `json:"ntfy_auth_token,omitempty"`
ExpoAccessToken *string `json:"expo_access_token,omitempty"`
}
// MaxConfigBodyBytes caps the PUT body size. Push tokens are typically
// well under 1 KB but we leave headroom.
const MaxConfigBodyBytes = 16 * 1024
// pushConfigManager is the concrete dependency the Handlers struct holds
// — a *push.Manager. We extract it via a small interface for tests.
type pushConfigManager interface {
IsConfigured(ctx interface{ Done() <-chan struct{} }, namespace string) bool
Invalidate(namespace string)
}
// GetConfigHandler — GET /v1/push/config. Returns the namespace's current
// push provider config with sensitive fields REDACTED to boolean flags.
//
// Always 200 + canonical envelope-free body when the request is well-formed
// (clients can rely on the shape: an absent provider just shows
// `has_ntfy_auth_token: false`). 503 only when the config store itself
// isn't available on this gateway (e.g. push subsystem disabled).
func (h *Handlers) GetConfigHandler(w http.ResponseWriter, r *http.Request) {
if h.configStore == nil {
writeError(w, http.StatusServiceUnavailable,
"push config store not available on this gateway")
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
}
cfg, err := h.configStore.Get(boundCtx(r), ns)
if err != nil && !errors.Is(err, push.ErrConfigNotFound) {
h.logger.ComponentWarn("push", "config GET failed",
zap.String("namespace", ns), zap.Error(err))
writeError(w, http.StatusInternalServerError, "failed to load config")
return
}
// Not found → return empty redacted config. Clients distinguish
// "configured" via the boolean fields.
if cfg == nil {
writeJSON(w, http.StatusOK, push.RedactedConfig{Namespace: ns})
return
}
writeJSON(w, http.StatusOK, cfg.Redacted())
}
// PutConfigHandler — PUT /v1/push/config. Updates the namespace's push
// provider config. Field-level semantics: nil JSON values leave the
// existing field untouched; explicit empty-strings clear it.
//
// On success returns the redacted config (same shape as GET) so clients
// can confirm what's now in place without echoing back the credentials.
//
// Invalidates the manager's cached dispatcher for this namespace so the
// next push send rebuilds with the fresh config.
func (h *Handlers) PutConfigHandler(w http.ResponseWriter, r *http.Request) {
if h.configStore == nil {
writeError(w, http.StatusServiceUnavailable,
"push config store not available on this gateway")
return
}
if r.Method != http.MethodPut && r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method not allowed (use PUT)")
return
}
ns := resolveNamespace(r)
if ns == "" {
writeError(w, http.StatusForbidden, "namespace not resolved")
return
}
caller := resolveCallerUserID(r)
if caller == "" {
writeError(w, http.StatusUnauthorized, "user authentication required")
return
}
r.Body = http.MaxBytesReader(w, r.Body, MaxConfigBodyBytes)
var body PutConfigRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid body: expected JSON")
return
}
// Validate URL fields look reasonable. We don't do hostname resolution
// here (slow, flaky); just reject obviously-wrong schemes.
if body.NtfyBaseURL != nil && *body.NtfyBaseURL != "" {
if !strings.HasPrefix(*body.NtfyBaseURL, "http://") &&
!strings.HasPrefix(*body.NtfyBaseURL, "https://") {
writeError(w, http.StatusBadRequest,
"ntfy_base_url must start with http:// or https://")
return
}
}
// Read existing for merge — PUT semantics are field-level, not
// whole-document replace.
existing, err := h.configStore.Get(boundCtx(r), ns)
if err != nil && !errors.Is(err, push.ErrConfigNotFound) {
h.logger.ComponentWarn("push", "config GET-before-PUT failed",
zap.String("namespace", ns), zap.Error(err))
writeError(w, http.StatusInternalServerError, "failed to load config")
return
}
cfg := push.Config{Namespace: ns, UpdatedAt: time.Now().Unix(), UpdatedBy: caller}
if existing != nil {
cfg.NtfyBaseURL = existing.NtfyBaseURL
cfg.NtfyAuthToken = existing.NtfyAuthToken
cfg.ExpoAccessToken = existing.ExpoAccessToken
}
if body.NtfyBaseURL != nil {
cfg.NtfyBaseURL = *body.NtfyBaseURL
}
if body.NtfyAuthToken != nil {
cfg.NtfyAuthToken = *body.NtfyAuthToken
}
if body.ExpoAccessToken != nil {
cfg.ExpoAccessToken = *body.ExpoAccessToken
}
if err := h.configStore.Upsert(boundCtx(r), cfg); err != nil {
h.logger.ComponentWarn("push", "config PUT failed",
zap.String("namespace", ns), zap.Error(err))
writeError(w, http.StatusInternalServerError, "failed to save config")
return
}
if h.manager != nil {
h.manager.Invalidate(ns)
}
h.logger.ComponentInfo("push", "config updated",
zap.String("namespace", ns),
zap.String("updated_by", caller),
zap.Bool("has_ntfy_url", cfg.NtfyBaseURL != ""),
zap.Bool("has_ntfy_auth_token", cfg.NtfyAuthToken != ""),
zap.Bool("has_expo_access_token", cfg.ExpoAccessToken != ""),
)
writeJSON(w, http.StatusOK, cfg.Redacted())
}
// DeleteConfigHandler — DELETE /v1/push/config. Clears the namespace's
// row entirely; push reverts to gateway YAML defaults (or 503 "not
// configured" if no defaults).
func (h *Handlers) DeleteConfigHandler(w http.ResponseWriter, r *http.Request) {
if h.configStore == nil {
writeError(w, http.StatusServiceUnavailable,
"push config store not available on this gateway")
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
}
caller := resolveCallerUserID(r)
if caller == "" {
writeError(w, http.StatusUnauthorized, "user authentication required")
return
}
if err := h.configStore.Delete(boundCtx(r), ns); err != nil {
h.logger.ComponentWarn("push", "config DELETE failed",
zap.String("namespace", ns), zap.Error(err))
writeError(w, http.StatusInternalServerError, "failed to delete config")
return
}
if h.manager != nil {
h.manager.Invalidate(ns)
}
h.logger.ComponentInfo("push", "config cleared",
zap.String("namespace", ns),
zap.String("cleared_by", caller),
)
writeJSON(w, http.StatusOK, map[string]string{"status": "cleared"})
}

View File

@ -0,0 +1,307 @@
package push
import (
"encoding/json"
"errors"
"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) {
// Either the per-namespace manager (preferred) or the legacy single
// dispatcher must be present. Both nil = push not configured at all.
if h.manager == nil && 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,
}
// Prefer the per-namespace Manager when present so per-namespace
// config (set via PUT /v1/push/config) takes effect. Fall back to the
// legacy single dispatcher only when no Manager is wired.
var sendErr error
if h.manager != nil {
sendErr = h.manager.SendToUser(boundCtx(r), ns, body.UserID, msg)
if errors.Is(sendErr, push.ErrPushNotConfigured) {
writeError(w, http.StatusServiceUnavailable, sendErr.Error())
return
}
} else {
sendErr = h.dispatcher.SendToUser(boundCtx(r), ns, body.UserID, msg)
}
if sendErr != 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(sendErr))
writeError(w, http.StatusBadGateway, "one or more devices failed")
return
}
writeJSON(w, http.StatusOK, SendResponse{Status: "ok"})
}
// extractIDFromPath returns the trailing path segment after `prefix`, or
// empty string if the path doesn't match. Used because the gateway uses
// the standard `net/http` mux which doesn't extract path params.
func extractIDFromPath(urlPath, prefix string) string {
if !strings.HasPrefix(urlPath, prefix) {
return ""
}
rest := urlPath[len(prefix):]
// Drop any query string (shouldn't normally appear in path here).
if i := strings.IndexAny(rest, "?#/"); i >= 0 {
rest = rest[:i]
}
return rest
}

View File

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

View File

@ -0,0 +1,179 @@
// 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.
//
// dispatcher is the legacy single-tier dispatcher (kept for the device
// register/list/delete + send paths). manager is the per-namespace
// dispatcher built on top of ConfigStore (new in bug #220 follow-up);
// when both are present, send paths route through manager so per-namespace
// config wins.
//
// configStore + manager may be nil on gateways with push fully disabled —
// the corresponding endpoints return 503.
type Handlers struct {
dispatcher *push.PushDispatcher
manager *push.Manager
store push.PushDeviceStore
configStore push.ConfigStore
logger *logging.ColoredLogger
}
// NewHandlers constructs a Handlers with the legacy single-namespace
// dispatcher only. Use NewHandlersWithManager for per-namespace config
// support (bug #220 follow-up).
func NewHandlers(dispatcher *push.PushDispatcher, store push.PushDeviceStore, logger *logging.ColoredLogger) *Handlers {
return &Handlers{
dispatcher: dispatcher,
store: store,
logger: logger,
}
}
// NewHandlersWithManager constructs Handlers wired to a Manager + ConfigStore
// for tenant-self-service per-namespace configuration. Send paths use the
// manager when present so per-namespace ntfy/expo settings take effect.
func NewHandlersWithManager(
manager *push.Manager,
configStore push.ConfigStore,
deviceStore push.PushDeviceStore,
logger *logging.ColoredLogger,
) *Handlers {
return &Handlers{
manager: manager,
configStore: configStore,
store: deviceStore,
logger: logger,
}
}
// RegisterDeviceRequest is the body of POST /v1/push/devices.
//
// `device_id` is an app-supplied stable identifier (e.g. the OS-assigned
// device UUID). Combined with (namespace, user_id) it uniquely identifies
// the registration; re-posting with the same device_id updates the token.
//
// `token` is provider-specific:
// - ntfy: the topic path the device subscribes to (e.g. "ns/myapp/user-1")
// - expo: an ExponentPushToken[...]
// - apns: a hex APNs device token (future)
type RegisterDeviceRequest struct {
DeviceID string `json:"device_id"`
Provider string `json:"provider"` // "ntfy" | "expo" | "apns"
Token string `json:"token"`
Platform string `json:"platform,omitempty"` // "ios" | "android" | "web"
AppVersion string `json:"app_version,omitempty"`
}
// RegisterDeviceResponse is the body of POST /v1/push/devices.
type RegisterDeviceResponse struct {
Status string `json:"status"`
}
// PushDeviceView is the safe (token-omitting) representation returned
// by GET /v1/push/devices.
type PushDeviceView struct {
ID string `json:"id"`
DeviceID string `json:"device_id"`
Provider string `json:"provider"`
Platform string `json:"platform,omitempty"`
AppVersion string `json:"app_version,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
LastSeen int64 `json:"last_seen,omitempty"`
}
// SendRequest is the body of POST /v1/push/send.
//
// The dispatcher fans out to all of `user_id`'s registered devices in
// the caller's namespace. Auth scope: see SendHandler — currently
// requires the caller to act on behalf of their own namespace; finer
// per-user authorization is the app's responsibility.
type SendRequest struct {
UserID string `json:"user_id"`
Title string `json:"title"`
Body string `json:"body"`
Channel string `json:"channel,omitempty"`
Priority string `json:"priority,omitempty"` // "high" | "normal" | "" (default)
Badge int `json:"badge,omitempty"`
Sound string `json:"sound,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
}
// SendResponse is the body of POST /v1/push/send.
type SendResponse struct {
Status string `json:"status"`
}
// resolveNamespace pulls the namespace set by auth middleware out of context.
func resolveNamespace(r *http.Request) string {
if v := r.Context().Value(ctxkeys.NamespaceOverride); v != nil {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// resolveCallerUserID extracts the JWT subject (typically the wallet) of
// the caller, or empty if the request was authenticated by API key only.
func resolveCallerUserID(r *http.Request) string {
if v := r.Context().Value(ctxkeys.JWT); v != nil {
if claims, ok := v.(*auth.JWTClaims); ok && claims != nil {
return claims.Sub
}
}
return ""
}
func writeError(w http.ResponseWriter, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// pickPriority maps the wire-format priority string to the typed enum.
func pickPriority(s string) push.PushPriority {
switch s {
case "high":
return push.PriorityHigh
case "normal":
return push.PriorityNormal
default:
return push.PriorityNormal
}
}
// boundCtx returns a request-scoped context with no extra wrapping;
// kept as a seam for future scope (rate-limit context etc.).
func boundCtx(r *http.Request) context.Context { return r.Context() }

View File

@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/DeBrosOfficial/network/pkg/httputil"
"github.com/DeBrosOfficial/network/pkg/serverless"
"go.uber.org/zap"
)
@ -126,7 +127,11 @@ func (h *ServerlessHandlers) DeployFunction(w http.ResponseWriter, r *http.Reque
zap.String("name", def.Name),
zap.Error(err),
)
writeError(w, http.StatusInternalServerError, "Failed to deploy: "+err.Error())
// Use the typed function-deploy code so clients can distinguish
// "registry rejected this binary" from generic 500s.
writeRPCError(w, http.StatusInternalServerError,
httputil.ErrCodeFunctionDeploy,
"Failed to deploy: "+err.Error())
return
}
@ -168,6 +173,43 @@ func (h *ServerlessHandlers) DeployFunction(w http.ResponseWriter, r *http.Reque
}
}
// Register Cron triggers from definition. Mirrors the PubSub branch above:
// stale rows (from a previous deploy whose manifest had different cron
// schedules) are cleared first, then the manifest's expressions are
// re-added. Without this, manifest-driven cron schedules silently never
// fired (feature #65 audit).
//
// We always run the RemoveByFunction even when CronExpressions is empty,
// otherwise editing a manifest from `cron_expressions: ["0 3 * * *"]` to
// `cron_expressions: []` would leave the old schedule in place.
if h.cronStore != nil && fn != nil {
if err := h.cronStore.RemoveByFunction(ctx, fn.ID); err != nil {
h.logger.Warn("Failed to clear stale cron triggers",
zap.String("function", def.Name),
zap.Error(err))
}
// Dedupe identical expressions so a manifest accident
// (`cron_expressions: ["0 3 * * *", "0 3 * * *"]`) doesn't fire the
// function twice every tick.
seen := make(map[string]struct{}, len(def.CronExpressions))
for _, expr := range def.CronExpressions {
if _, dup := seen[expr]; dup {
continue
}
seen[expr] = struct{}{}
if _, err := h.cronStore.Add(ctx, fn.ID, expr); err != nil {
// Bad expression in a manifest is a user error worth surfacing
// but not blocking the deploy — the function itself is fine,
// only the schedule is dropped. Logged at WARN so operators
// see it; the deploy response still reports success.
h.logger.Warn("Failed to register cron trigger from manifest",
zap.String("function", def.Name),
zap.String("cron_expression", expr),
zap.Error(err))
}
}
}
writeJSON(w, http.StatusCreated, map[string]interface{}{
"message": "Function deployed successfully",
"function": fn,
@ -181,7 +223,51 @@ func writeJSON(w http.ResponseWriter, code int, v any) {
_ = json.NewEncoder(w).Encode(v)
}
// writeError writes a standardized JSON error
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]any{"error": msg})
// writeError emits the canonical RPC error envelope (bug #212 fix).
//
// Derives the typed RPCErrorCode from the HTTP status — sufficient for
// most call sites. Callers that need to surface a specific code (e.g.
// FUNCTION_EXECUTION_FAILED on a 500 from the invoker) should use
// writeRPCError directly.
//
// Wire shape (always):
//
// {"ok": false, "error": {"code": "...", "message": "...", "retryable": ...}}
func writeError(w http.ResponseWriter, status int, msg string) {
httputil.WriteRPCError(w, status, codeForStatus(status), msg)
}
// writeRPCError is the typed helper for call sites that need to set a
// specific error code (e.g. distinguishing FUNCTION_EXECUTION_FAILED
// from a generic INTERNAL on a 500).
func writeRPCError(w http.ResponseWriter, status int, code httputil.RPCErrorCode, msg string, opts ...httputil.RPCErrorOption) {
httputil.WriteRPCError(w, status, code, msg, opts...)
}
// codeForStatus maps HTTP status to the canonical RPCErrorCode. For
// statuses that map to multiple codes (500 → INTERNAL or
// FUNCTION_EXECUTION_FAILED), the caller picks via writeRPCError.
func codeForStatus(status int) httputil.RPCErrorCode {
switch status {
case http.StatusBadRequest:
return httputil.ErrCodeValidationFailed
case http.StatusUnauthorized:
return httputil.ErrCodeUnauthorized
case http.StatusForbidden:
return httputil.ErrCodeForbidden
case http.StatusNotFound:
return httputil.ErrCodeNotFound
case http.StatusConflict:
return httputil.ErrCodeConflict
case http.StatusRequestEntityTooLarge:
return httputil.ErrCodePayloadTooLarge
case http.StatusTooManyRequests:
return httputil.ErrCodeRateLimited
case http.StatusServiceUnavailable:
return httputil.ErrCodeServiceUnavailable
case http.StatusGatewayTimeout:
return httputil.ErrCodeTimeout
default:
return httputil.ErrCodeInternal
}
}

View File

@ -20,12 +20,13 @@ import (
// mockRegistry implements serverless.FunctionRegistry for testing.
type mockRegistry struct {
functions map[string]*serverless.Function
logs []serverless.LogEntry
getErr error
listErr error
deleteErr error
logsErr error
functions map[string]*serverless.Function
logs []serverless.LogEntry
invocations []serverless.Invocation
getErr error
listErr error
deleteErr error
logsErr error
}
func newMockRegistry() *mockRegistry {
@ -78,6 +79,13 @@ func (m *mockRegistry) GetLogs(_ context.Context, _, _ string, _ int) ([]serverl
return m.logs, nil
}
func (m *mockRegistry) GetInvocations(_ context.Context, _, _ string, _ int) ([]serverless.Invocation, error) {
if m.logsErr != nil {
return nil, m.logsErr
}
return m.invocations, nil
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@ -90,10 +98,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,
)
@ -576,7 +588,7 @@ func TestDeployFunction_Base64WASMNotSupported(t *testing.T) {
t.Errorf("expected 400, got %d", rec.Code)
}
respBody := decodeBody(t, rec)
errMsg, _ := respBody["error"].(string)
errMsg := errMessageFromEnvelope(respBody)
if !strings.Contains(errMsg, "Base64 WASM upload not supported") {
t.Errorf("expected base64 not supported error, got %q", errMsg)
}
@ -596,12 +608,28 @@ func TestDeployFunction_JSONMissingWASM(t *testing.T) {
t.Errorf("expected 400, got %d", rec.Code)
}
respBody := decodeBody(t, rec)
errMsg, _ := respBody["error"].(string)
errMsg := errMessageFromEnvelope(respBody)
if !strings.Contains(errMsg, "name") {
t.Errorf("expected name-related error, got %q", errMsg)
}
}
// errMessageFromEnvelope extracts the human-readable message from the
// canonical RPC error envelope: {"ok": false, "error": {"message": "..."}}.
// Returns "" if the envelope shape is unexpected; tests use Contains on
// the result so a missing message will still fail loudly.
func errMessageFromEnvelope(body map[string]interface{}) string {
if body == nil {
return ""
}
errObj, ok := body["error"].(map[string]interface{})
if !ok {
return ""
}
msg, _ := errObj["message"].(string)
return msg
}
// ---------------------------------------------------------------------------
// Tests: DeleteFunction validation
// ---------------------------------------------------------------------------
@ -643,10 +671,14 @@ func TestDeleteFunction_NotFound(t *testing.T) {
// Tests: GetFunctionLogs
// ---------------------------------------------------------------------------
func TestGetFunctionLogs_Success(t *testing.T) {
// TestGetFunctionLogs_Success_DefaultInvocationsView locks in the bug-#211 fix:
// the default endpoint returns invocation history (always populated when the
// function has been invoked), NOT WASM-emitted log entries (often empty).
func TestGetFunctionLogs_Success_DefaultInvocationsView(t *testing.T) {
reg := newMockRegistry()
reg.logs = []serverless.LogEntry{
{Level: "info", Message: "hello"},
reg.invocations = []serverless.Invocation{
{ID: "inv-1", RequestID: "req-A", Status: "success", DurationMS: 12},
{ID: "inv-2", RequestID: "req-B", Status: "error", ErrorMessage: "boom"},
}
h := newTestHandlers(reg)
@ -663,8 +695,41 @@ func TestGetFunctionLogs_Success(t *testing.T) {
t.Errorf("expected name 'myFunc', got %v", body["name"])
}
count, ok := body["count"].(float64)
if !ok || int(count) != 2 {
t.Errorf("expected count=2, got %v", body["count"])
}
if _, ok := body["invocations"]; !ok {
t.Error("expected response to include 'invocations' key in default view")
}
if _, ok := body["logs"]; ok {
t.Error("default view should NOT return 'logs' key (legacy WASM-only)")
}
}
// TestGetFunctionLogs_WASMOnly preserves the legacy "raw WASM-emitted lines"
// view via the wasm_only=1 query param.
func TestGetFunctionLogs_WASMOnly(t *testing.T) {
reg := newMockRegistry()
reg.logs = []serverless.LogEntry{
{Level: "info", Message: "hello"},
}
h := newTestHandlers(reg)
req := httptest.NewRequest(http.MethodGet, "/v1/functions/myFunc/logs?namespace=test&wasm_only=1", nil)
rec := httptest.NewRecorder()
h.GetFunctionLogs(rec, req, "myFunc")
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
body := decodeBody(t, rec)
count, ok := body["count"].(float64)
if !ok || int(count) != 1 {
t.Errorf("expected count=1, got %v", body["count"])
t.Errorf("expected count=1 from logs, got %v", body["count"])
}
if _, ok := body["logs"]; !ok {
t.Error("wasm_only=1 should return 'logs' key")
}
}
@ -713,10 +778,24 @@ func TestWriteError(t *testing.T) {
if rec.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", rec.Code)
}
body := map[string]string{}
json.NewDecoder(rec.Body).Decode(&body)
if body["error"] != "something went wrong" {
t.Errorf("expected error message 'something went wrong', got %q", body["error"])
// writeError now emits the canonical RPC envelope (bug #212 fix).
// Shape: {"ok": false, "error": {"code": "VALIDATION_FAILED", "message": "...", "retryable": false}}
body := map[string]interface{}{}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if ok, _ := body["ok"].(bool); ok {
t.Error("ok must be false on error envelope")
}
errObj, ok := body["error"].(map[string]interface{})
if !ok {
t.Fatalf("error must be an object, got %T: %v", body["error"], body["error"])
}
if msg, _ := errObj["message"].(string); msg != "something went wrong" {
t.Errorf("expected message 'something went wrong', got %q", msg)
}
if code, _ := errObj["code"].(string); code != "VALIDATION_FAILED" {
t.Errorf("expected code VALIDATION_FAILED for 400, got %q", code)
}
}

View File

@ -2,15 +2,43 @@ package serverless
import (
"context"
"errors"
"io"
"net"
"net/http"
"strconv"
"strings"
"time"
"github.com/DeBrosOfficial/network/pkg/httputil"
"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) {
@ -51,38 +79,65 @@ func (h *ServerlessHandlers) InvokeFunction(w http.ResponseWriter, r *http.Reque
defer cancel()
req := &serverless.InvokeRequest{
Namespace: namespace,
FunctionName: name,
Version: version,
Input: input,
TriggerType: serverless.TriggerTypeHTTP,
CallerWallet: callerWallet,
Namespace: namespace,
FunctionName: name,
Version: version,
Input: input,
TriggerType: serverless.TriggerTypeHTTP,
CallerWallet: callerWallet,
CallerIP: extractRemoteIP(r),
CallerClaims: h.getCallerClaimsFromRequest(r),
CallerJWTSubject: h.getJWTSubjectFromRequest(r),
}
resp, err := h.invoker.Invoke(ctx, req)
if err != nil {
statusCode := http.StatusInternalServerError
if serverless.IsNotFound(err) {
statusCode = http.StatusNotFound
} else if serverless.IsResourceExhausted(err) {
statusCode = http.StatusTooManyRequests
} else if serverless.IsUnauthorized(err) {
statusCode = http.StatusUnauthorized
}
// Bug #212: every error path here emits the canonical RPC
// envelope. error.message is always populated (falls back to
// err.Error() then to a default per code).
if resp == nil {
writeJSON(w, statusCode, map[string]interface{}{
"error": err.Error(),
})
// Rate-limit errors carry a retry hint we surface as both the
// HTTP Retry-After header and the envelope field.
var rle *serverless.RateLimitedError
if errors.As(err, &rle) {
opts := []httputil.RPCErrorOption{}
if rle.RetryAfter > 0 {
opts = append(opts, httputil.WithRetryAfter(rle.RetryAfter.Seconds()))
}
if resp != nil && resp.RequestID != "" {
opts = append(opts, httputil.WithRequestID(resp.RequestID))
}
writeRPCError(w, http.StatusTooManyRequests,
httputil.ErrCodeRateLimited, err.Error(), opts...)
return
}
writeJSON(w, statusCode, map[string]interface{}{
"request_id": resp.RequestID,
"status": resp.Status,
"error": resp.Error,
"duration_ms": resp.DurationMS,
})
// Map domain-typed errors to (status, RPC code).
statusCode := http.StatusInternalServerError
errCode := httputil.ErrCodeFunctionExecution
switch {
case serverless.IsNotFound(err):
statusCode = http.StatusNotFound
errCode = httputil.ErrCodeNotFound
case serverless.IsResourceExhausted(err):
statusCode = http.StatusTooManyRequests
errCode = httputil.ErrCodeRateLimited
case serverless.IsUnauthorized(err):
statusCode = http.StatusUnauthorized
errCode = httputil.ErrCodeUnauthorized
}
// Pick the most informative message: function-side resp.Error
// (if set) is more actionable than the wrapping err.Error().
msg := err.Error()
if resp != nil && resp.Error != "" {
msg = resp.Error
}
opts := []httputil.RPCErrorOption{}
if resp != nil && resp.RequestID != "" {
opts = append(opts, httputil.WithRequestID(resp.RequestID))
}
writeRPCError(w, statusCode, errCode, msg, opts...)
return
}

View File

@ -10,7 +10,26 @@ import (
)
// GetFunctionLogs handles GET /v1/functions/{name}/logs
// Retrieves execution logs for a specific function.
//
// Returns invocation history (always populated when the function has been
// invoked) with any associated WASM-emitted log entries nested per record.
// This is the answer to "what happened when this function ran" — the older
// behavior (only WASM-emitted entries) was useless on functions that
// don't call log_info / log_error and surfaced as "No logs found" to users.
//
// Optional query params:
// - limit: max records (default 50, capped at 500)
// - wasm_only=1: return ONLY WASM-emitted log rows (legacy view)
//
// Response:
//
// {
// "name": "...",
// "namespace": "...",
// "invocations": [ ...records... ], // when wasm_only is unset
// "logs": [ ...LogEntry... ], // when wasm_only=1
// "count": N
// }
func (h *ServerlessHandlers) GetFunctionLogs(w http.ResponseWriter, r *http.Request, name string) {
namespace := r.URL.Query().Get("namespace")
if namespace == "" {
@ -22,31 +41,56 @@ func (h *ServerlessHandlers) GetFunctionLogs(w http.ResponseWriter, r *http.Requ
return
}
limit := 100
limit := 50
if lStr := r.URL.Query().Get("limit"); lStr != "" {
if l, err := strconv.Atoi(lStr); err == nil {
if l, err := strconv.Atoi(lStr); err == nil && l > 0 {
limit = l
}
}
if limit > 500 {
limit = 500
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
logs, err := h.registry.GetLogs(ctx, namespace, name, limit)
// Legacy "WASM-emitted only" view. Kept for backward compat — most
// dashboards / clients should use the default invocations view.
if r.URL.Query().Get("wasm_only") == "1" {
logs, err := h.registry.GetLogs(ctx, namespace, name, limit)
if err != nil {
h.logger.Error("Failed to get WASM logs",
zap.String("name", name),
zap.String("namespace", namespace),
zap.Error(err),
)
writeError(w, http.StatusInternalServerError, "Failed to get logs")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"name": name,
"namespace": namespace,
"logs": logs,
"count": len(logs),
})
return
}
invocations, err := h.registry.GetInvocations(ctx, namespace, name, limit)
if err != nil {
h.logger.Error("Failed to get function logs",
h.logger.Error("Failed to get function invocations",
zap.String("name", name),
zap.String("namespace", namespace),
zap.Error(err),
)
writeError(w, http.StatusInternalServerError, "Failed to get logs")
writeError(w, http.StatusInternalServerError, "Failed to get invocations")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"name": name,
"namespace": namespace,
"logs": logs,
"count": len(logs),
"name": name,
"namespace": namespace,
"invocations": invocations,
"count": len(invocations),
})
}

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