mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-06-16 22:54:12 +00:00
commit
c172f1355f
85
.github/workflows/ci.yml
vendored
Normal file
85
.github/workflows/ci.yml
vendored
Normal 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
|
||||
39
.github/workflows/publish-sdk.yml
vendored
39
.github/workflows/publish-sdk.yml
vendored
@ -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]"
|
||||
|
||||
18
.github/workflows/release-apt.yml
vendored
18
.github/workflows/release-apt.yml
vendored
@ -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
|
||||
|
||||
27
.github/workflows/release.yaml
vendored
27
.github/workflows/release.yaml
vendored
@ -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
5
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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}}"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
242
core/cmd/sni-router/main.go
Normal file
@ -0,0 +1,242 @@
|
||||
// Command sni-router is a TLS-level Server Name Indication router.
|
||||
//
|
||||
// It listens on a public TCP port (typically :443), peeks at the TLS
|
||||
// ClientHello SNI on each connection, and forwards the raw stream to
|
||||
// a configured backend. It does NOT terminate TLS — encrypted bytes
|
||||
// pass through verbatim. This lets one port serve multiple TLS-speaking
|
||||
// backends (HTTPS for the gateway, TURN-over-TLS for stealth WebRTC).
|
||||
//
|
||||
// See pkg/sniproxy for the underlying library.
|
||||
//
|
||||
// Configuration: YAML file at --config (defaults to ~/.orama/sni-router.yaml).
|
||||
//
|
||||
// Example sni-router.yaml:
|
||||
//
|
||||
// listen: ":443"
|
||||
// client_hello_timeout: 5s
|
||||
// backend_dial_timeout: 5s
|
||||
// max_concurrent_conns: 10000
|
||||
// fallback:
|
||||
// name: caddy
|
||||
// addr: "127.0.0.1:8443"
|
||||
// routes:
|
||||
// - match: "cdn.example.com"
|
||||
// backend:
|
||||
// name: turn-tls
|
||||
// addr: "127.0.0.1:5349"
|
||||
// - match: "turn.example.com"
|
||||
// backend:
|
||||
// name: turn-tls
|
||||
// addr: "127.0.0.1:5349"
|
||||
// - match: "*.ns-myapp.example.com"
|
||||
// backend:
|
||||
// name: gateway
|
||||
// addr: "127.0.0.1:8443"
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/config"
|
||||
"github.com/DeBrosOfficial/network/pkg/logging"
|
||||
"github.com/DeBrosOfficial/network/pkg/sniproxy"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
)
|
||||
|
||||
// yamlBackend mirrors sniproxy.Backend for YAML decoding.
|
||||
type yamlBackend struct {
|
||||
Name string `yaml:"name"`
|
||||
Network string `yaml:"network"`
|
||||
Addr string `yaml:"addr"`
|
||||
}
|
||||
|
||||
// yamlRoute mirrors sniproxy.Route for YAML decoding.
|
||||
type yamlRoute struct {
|
||||
Match string `yaml:"match"`
|
||||
Backend yamlBackend `yaml:"backend"`
|
||||
}
|
||||
|
||||
// yamlConfig is the on-disk configuration shape.
|
||||
type yamlConfig struct {
|
||||
Listen string `yaml:"listen"`
|
||||
ClientHelloTimeout time.Duration `yaml:"client_hello_timeout"`
|
||||
BackendDialTimeout time.Duration `yaml:"backend_dial_timeout"`
|
||||
MaxConcurrentConns int `yaml:"max_concurrent_conns"`
|
||||
Fallback yamlBackend `yaml:"fallback"`
|
||||
Routes []yamlRoute `yaml:"routes"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
logger, err := logging.NewColoredLogger(logging.ComponentSNI, true)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to init logger: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.ComponentInfo(logging.ComponentSNI, "Starting SNI router",
|
||||
zap.String("version", version),
|
||||
zap.String("commit", commit))
|
||||
|
||||
cfg := parseConfig(logger)
|
||||
|
||||
router := sniproxy.NewRouter(toBackend(cfg.Fallback))
|
||||
router.Replace(toRoutes(cfg.Routes), toBackend(cfg.Fallback))
|
||||
|
||||
srv := sniproxy.NewServer(router, sniproxy.Config{
|
||||
ClientHelloTimeout: cfg.ClientHelloTimeout,
|
||||
BackendDialTimeout: cfg.BackendDialTimeout,
|
||||
MaxConcurrentConns: cfg.MaxConcurrentConns,
|
||||
}, logger.Logger)
|
||||
|
||||
ln, err := net.Listen("tcp", cfg.Listen)
|
||||
if err != nil {
|
||||
logger.ComponentError(logging.ComponentSNI, "Failed to listen",
|
||||
zap.String("addr", cfg.Listen), zap.Error(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.ComponentInfo(logging.ComponentSNI, "SNI router listening",
|
||||
zap.String("addr", cfg.Listen),
|
||||
zap.Int("routes", len(cfg.Routes)),
|
||||
zap.String("fallback", cfg.Fallback.Addr),
|
||||
)
|
||||
|
||||
// Run Serve in a goroutine so the main goroutine can wait on signals.
|
||||
serveErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
serveErrCh <- srv.Serve(ln)
|
||||
}()
|
||||
|
||||
// Wait for termination signal or unrecoverable Serve error.
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case sig := <-quit:
|
||||
logger.ComponentInfo(logging.ComponentSNI, "Shutdown signal received",
|
||||
zap.String("signal", sig.String()))
|
||||
case err := <-serveErrCh:
|
||||
logger.ComponentError(logging.ComponentSNI, "Serve returned",
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// Stop accepting new connections, then drain in-flight ones.
|
||||
_ = ln.Close()
|
||||
srv.Close()
|
||||
|
||||
logger.ComponentInfo(logging.ComponentSNI, "SNI router shutdown complete")
|
||||
}
|
||||
|
||||
func parseConfig(logger *logging.ColoredLogger) yamlConfig {
|
||||
configFlag := flag.String("config", "", "Config file path (absolute or filename in ~/.orama)")
|
||||
flag.Parse()
|
||||
|
||||
var configPath string
|
||||
var err error
|
||||
if *configFlag != "" {
|
||||
if filepath.IsAbs(*configFlag) {
|
||||
configPath = *configFlag
|
||||
} else {
|
||||
configPath, err = config.DefaultPath(*configFlag)
|
||||
if err != nil {
|
||||
logger.ComponentError(logging.ComponentSNI, "Failed to determine config path",
|
||||
zap.Error(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
configPath, err = config.DefaultPath("sni-router.yaml")
|
||||
if err != nil {
|
||||
logger.ComponentError(logging.ComponentSNI, "Failed to determine config path",
|
||||
zap.Error(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
logger.ComponentError(logging.ComponentSNI, "Config file not found",
|
||||
zap.String("path", configPath), zap.Error(err))
|
||||
fmt.Fprintf(os.Stderr, "\nConfig file not found at %s\n", configPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var y yamlConfig
|
||||
if err := config.DecodeStrict(strings.NewReader(string(data)), &y); err != nil {
|
||||
logger.ComponentError(logging.ComponentSNI, "Failed to parse SNI router config",
|
||||
zap.Error(err))
|
||||
fmt.Fprintf(os.Stderr, "Configuration parse error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if errs := validateConfig(&y); len(errs) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "\nSNI router configuration errors (%d):\n", len(errs))
|
||||
for _, e := range errs {
|
||||
fmt.Fprintf(os.Stderr, " - %s\n", e)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "\nPlease fix the configuration and try again.\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.ComponentInfo(logging.ComponentSNI, "Loaded SNI router configuration",
|
||||
zap.String("path", configPath),
|
||||
)
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
// validateConfig returns a non-empty slice of human-readable errors on misconfig.
|
||||
func validateConfig(y *yamlConfig) []string {
|
||||
var errs []string
|
||||
if y.Listen == "" {
|
||||
errs = append(errs, "listen: required (e.g. \":443\")")
|
||||
}
|
||||
if y.Fallback.Addr == "" {
|
||||
errs = append(errs, "fallback.addr: required (where to send unmatched SNIs, typically Caddy)")
|
||||
}
|
||||
for i, r := range y.Routes {
|
||||
if r.Match == "" {
|
||||
errs = append(errs, fmt.Sprintf("routes[%d].match: required", i))
|
||||
}
|
||||
if r.Backend.Addr == "" {
|
||||
errs = append(errs, fmt.Sprintf("routes[%d].backend.addr: required", i))
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func toBackend(b yamlBackend) sniproxy.Backend {
|
||||
network := b.Network
|
||||
if network == "" {
|
||||
network = "tcp"
|
||||
}
|
||||
return sniproxy.Backend{
|
||||
Name: b.Name,
|
||||
Network: network,
|
||||
Addr: b.Addr,
|
||||
}
|
||||
}
|
||||
|
||||
func toRoutes(in []yamlRoute) []sniproxy.Route {
|
||||
out := make([]sniproxy.Route, len(in))
|
||||
for i, r := range in {
|
||||
out[i] = sniproxy.Route{
|
||||
Match: r.Match,
|
||||
Backend: toBackend(r.Backend),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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
187
core/docs/STEALTH_TURN.md
Normal file
@ -0,0 +1,187 @@
|
||||
# Stealth TURN Deployment Guide
|
||||
|
||||
## What this is
|
||||
|
||||
A TLS-level SNI router that lets Orama serve TURN-over-TLS on `:443`,
|
||||
sharing the port with Caddy HTTPS. From a network observer's
|
||||
perspective, TURN traffic is indistinguishable from ordinary HTTPS —
|
||||
useful for users in regions that block standard VoIP ports (UAE, Saudi
|
||||
Arabia, China, Iran).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
TCP :443
|
||||
│
|
||||
┌─────────┴─────────┐
|
||||
│ orama-sni-router │ peeks SNI, forwards bytes
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
┌───────────────┼────────────────┐
|
||||
▼ ▼
|
||||
cdn.<base> *.<base>, <base>
|
||||
turn.<base> (everything else)
|
||||
│ │
|
||||
▼ ▼
|
||||
Pion TURN-TLS Caddy
|
||||
127.0.0.1:5349 127.0.0.1:8443
|
||||
(existing) (moved from :443)
|
||||
```
|
||||
|
||||
The router does **not** terminate TLS. It reads the unencrypted TLS
|
||||
ClientHello (first ~5 KB), inspects the SNI extension, and dials the
|
||||
matching backend. Encrypted bytes pass through verbatim.
|
||||
|
||||
## Components
|
||||
|
||||
- **Library:** `pkg/sniproxy/` — ClientHello parser, route table, TCP server
|
||||
- **Binary:** `cmd/sni-router/` (built as `bin/orama-sni-router`)
|
||||
- **Systemd unit:** `systemd/orama-sni-router.service`
|
||||
- **Config:** `~/.orama/sni-router.yaml`
|
||||
|
||||
## Deployment cutover
|
||||
|
||||
⚠️ **This change touches production `:443`. Stage on one node first, watch for 24h, then roll out.**
|
||||
|
||||
### 1. Reconfigure Caddy to listen on `:8443`
|
||||
|
||||
Update wherever the Caddy config is generated (`pkg/environments/production/installers/caddy.go`)
|
||||
so Caddy binds `:8443` (HTTPS) and `:8080` (HTTP) instead of `:443` and `:80`.
|
||||
|
||||
Drop `CAP_NET_BIND_SERVICE` from Caddy's systemd unit — it no longer needs privileged ports.
|
||||
|
||||
### 2. Provision the cert SAN for `cdn.<base-domain>`
|
||||
|
||||
Caddy's automatic Let's Encrypt flow needs to issue a cert covering
|
||||
`cdn.<base-domain>` and `cdn.ns-*.<base-domain>` so Pion TURN can read it
|
||||
on startup. Add these names to Caddy's TLS config block.
|
||||
|
||||
### 3. Drop `sni-router.yaml` config
|
||||
|
||||
Example for a single-namespace node:
|
||||
|
||||
```yaml
|
||||
listen: ":443"
|
||||
client_hello_timeout: 5s
|
||||
backend_dial_timeout: 5s
|
||||
max_concurrent_conns: 10000
|
||||
fallback:
|
||||
name: caddy
|
||||
addr: "127.0.0.1:8443"
|
||||
routes:
|
||||
- match: "cdn.example.com"
|
||||
backend:
|
||||
name: turn-tls
|
||||
addr: "127.0.0.1:5349"
|
||||
- match: "turn.example.com"
|
||||
backend:
|
||||
name: turn-tls
|
||||
addr: "127.0.0.1:5349"
|
||||
```
|
||||
|
||||
For multi-namespace, add per-namespace TURN backends (each namespace's
|
||||
TURN-TLS port is allocated by `pkg/namespace`):
|
||||
|
||||
```yaml
|
||||
- match: "cdn.ns-myapp.example.com"
|
||||
backend: { name: "turn-myapp", addr: "127.0.0.1:5349" }
|
||||
- match: "cdn.ns-other.example.com"
|
||||
backend: { name: "turn-other", addr: "127.0.0.1:5350" }
|
||||
```
|
||||
|
||||
### 4. Deploy + start in order
|
||||
|
||||
```bash
|
||||
# Install binary
|
||||
sudo cp bin-linux/orama-sni-router /opt/orama/bin/
|
||||
|
||||
# Install service
|
||||
sudo cp systemd/orama-sni-router.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Stop Caddy briefly (it's about to lose :443)
|
||||
sudo systemctl stop caddy
|
||||
|
||||
# Start the SNI router (it takes :443)
|
||||
sudo systemctl enable --now orama-sni-router
|
||||
|
||||
# Restart Caddy on its new port
|
||||
sudo systemctl start caddy
|
||||
|
||||
# Verify
|
||||
curl -v https://cdn.<base>:443 # should hit TURN backend (TLS handshake will fail; that's fine)
|
||||
curl -v https://<base>:443 # should hit Caddy (normal HTTPS response)
|
||||
```
|
||||
|
||||
### 5. Enable stealth in the gateway
|
||||
|
||||
Once the SNI router is live, tell the gateway to advertise the stealth URI:
|
||||
|
||||
```go
|
||||
// in gateway dependencies / startup
|
||||
webrtcHandlers.SetStealthCDNDomain("cdn.<base-domain>")
|
||||
```
|
||||
|
||||
The credentials handler will start including `turns:cdn.<base-domain>:443`
|
||||
in `POST /v1/webrtc/turn/credentials` responses automatically.
|
||||
|
||||
### 6. Monitor
|
||||
|
||||
```bash
|
||||
journalctl -u orama-sni-router.service -f
|
||||
journalctl -u caddy.service -f
|
||||
```
|
||||
|
||||
Watch for:
|
||||
- `Connection limit reached` warnings (bump `max_concurrent_conns`)
|
||||
- `backend dial failed` warnings (Caddy isn't listening on `:8443`, or TURN isn't on `:5349`)
|
||||
- `ClientHello peek failed` debugs (curious clients sending non-TLS to `:443` — usually port scanners)
|
||||
|
||||
## Rollback
|
||||
|
||||
If anything is wrong:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop orama-sni-router
|
||||
# Reconfigure Caddy back to :443 and restart
|
||||
sudo systemctl restart caddy
|
||||
```
|
||||
|
||||
Caddy reclaiming `:443` from the disabled router is the fastest way back to
|
||||
the previous topology.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **Dynamic route source:** today's router reads YAML once at startup. To
|
||||
pick up new namespaces without restart, implement a `RouteSource` that
|
||||
polls `pkg/namespace` for active TURN deployments. The library is
|
||||
already designed for `Router.Replace` to be called concurrently.
|
||||
- **TLS cert hot-reload:** Pion TURN reads the cert once at startup. When
|
||||
Caddy renews `cdn.<base-domain>`, Pion needs to be restarted to pick up
|
||||
the new cert. A small file-watcher service (or a periodic restart in
|
||||
off-peak hours) handles this for now.
|
||||
|
||||
## What clients see
|
||||
|
||||
Once enabled, the credentials response gains one entry:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "...",
|
||||
"password": "...",
|
||||
"ttl": 600,
|
||||
"uris": [
|
||||
"turn:turn.example.com:3478?transport=udp",
|
||||
"turn:turn.example.com:3478?transport=tcp",
|
||||
"turns:turn.example.com:5349",
|
||||
"turns:cdn.example.com:443"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Browsers iterate ICE candidates; users in restricted regions will silently
|
||||
succeed via the `:443` URI when others fail. No client-side change is
|
||||
required.
|
||||
14
core/migrations/020_node_operators.sql
Normal file
14
core/migrations/020_node_operators.sql
Normal file
@ -0,0 +1,14 @@
|
||||
-- Add operator wallet tracking to nodes.
|
||||
-- operator_wallet links nodes to the wallet that provisioned them.
|
||||
|
||||
ALTER TABLE dns_nodes ADD COLUMN operator_wallet TEXT;
|
||||
ALTER TABLE dns_nodes ADD COLUMN environment TEXT DEFAULT 'production';
|
||||
ALTER TABLE dns_nodes ADD COLUMN ssh_user TEXT DEFAULT 'root';
|
||||
ALTER TABLE dns_nodes ADD COLUMN role TEXT DEFAULT 'node';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dns_nodes_operator ON dns_nodes(operator_wallet);
|
||||
CREATE INDEX IF NOT EXISTS idx_dns_nodes_environment ON dns_nodes(environment);
|
||||
|
||||
ALTER TABLE wireguard_peers ADD COLUMN operator_wallet TEXT;
|
||||
|
||||
ALTER TABLE invite_tokens ADD COLUMN operator_wallet TEXT;
|
||||
28
core/migrations/021_pubsub_trigger_patterns.sql
Normal file
28
core/migrations/021_pubsub_trigger_patterns.sql
Normal file
@ -0,0 +1,28 @@
|
||||
-- =============================================================================
|
||||
-- 021_pubsub_trigger_patterns.sql
|
||||
--
|
||||
-- Add `topic_pattern` column alongside the existing `topic` column to
|
||||
-- function_pubsub_triggers. The new column may contain SQLite GLOB
|
||||
-- patterns (e.g. "presence:*") in addition to exact topic names.
|
||||
--
|
||||
-- This is intentionally ADDITIVE rather than a column rename to remain
|
||||
-- safe under rolling upgrades:
|
||||
-- - Old binaries continue reading `topic` and keep working.
|
||||
-- - New binaries read `topic_pattern` (which is back-filled from
|
||||
-- `topic` for existing rows) and write BOTH columns.
|
||||
-- A future migration can DROP COLUMN topic once every node is on the
|
||||
-- new release.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE function_pubsub_triggers
|
||||
ADD COLUMN topic_pattern TEXT NOT NULL DEFAULT '';
|
||||
|
||||
UPDATE function_pubsub_triggers
|
||||
SET topic_pattern = topic
|
||||
WHERE topic_pattern = '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_function_pubsub_triggers_function
|
||||
ON function_pubsub_triggers(function_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_function_pubsub_triggers_enabled
|
||||
ON function_pubsub_triggers(enabled);
|
||||
20
core/migrations/022_aggregation_windows.sql
Normal file
20
core/migrations/022_aggregation_windows.sql
Normal file
@ -0,0 +1,20 @@
|
||||
-- =============================================================================
|
||||
-- 022_aggregation_windows.sql
|
||||
--
|
||||
-- Add per-trigger aggregation parameters to function_pubsub_triggers.
|
||||
--
|
||||
-- aggregation_window_ms = 0 means "no aggregation, invoke once per event"
|
||||
-- (the existing behaviour). Any positive value enables buffering of events
|
||||
-- in-memory on the dispatching node; the function is invoked once per
|
||||
-- window with a batched payload.
|
||||
--
|
||||
-- aggregation_max_batch_size caps the per-window batch. When the buffer
|
||||
-- reaches this size, the dispatcher flushes immediately even if the
|
||||
-- window timer hasn't fired yet.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE function_pubsub_triggers
|
||||
ADD COLUMN aggregation_window_ms INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE function_pubsub_triggers
|
||||
ADD COLUMN aggregation_max_batch_size INTEGER NOT NULL DEFAULT 100;
|
||||
33
core/migrations/023_push_devices.sql
Normal file
33
core/migrations/023_push_devices.sql
Normal file
@ -0,0 +1,33 @@
|
||||
-- =============================================================================
|
||||
-- 023_push_devices.sql
|
||||
--
|
||||
-- Per-namespace, per-user push notification device registry.
|
||||
--
|
||||
-- token_encrypted is AES-256-GCM ciphertext (prefix 'enc:') derived via
|
||||
-- pkg/secrets. Tokens are sensitive — they let the holder spam a user's
|
||||
-- device — so they are never returned via any API or written to logs.
|
||||
--
|
||||
-- provider matches a registered push.PushProvider name:
|
||||
-- 'ntfy', 'expo', 'apns', 'fcm' (future), ...
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
namespace TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
token_encrypted TEXT NOT NULL,
|
||||
platform TEXT,
|
||||
app_version TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_seen INTEGER,
|
||||
UNIQUE(namespace, user_id, device_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_push_devices_user
|
||||
ON push_devices(namespace, user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_push_devices_provider
|
||||
ON push_devices(provider);
|
||||
18
core/migrations/024_namespace_publish_seq.sql
Normal file
18
core/migrations/024_namespace_publish_seq.sql
Normal file
@ -0,0 +1,18 @@
|
||||
-- =============================================================================
|
||||
-- 024_namespace_publish_seq.sql
|
||||
--
|
||||
-- Per-namespace monotonically-increasing sequence number assigned by
|
||||
-- exec_and_publish (plan 08). The seq is included in the wake-up payload so
|
||||
-- subscribers can detect "I'm behind, retry" gaps caused by cross-node
|
||||
-- replication lag between the leader's commit and the gossipsub message.
|
||||
--
|
||||
-- The row is upserted in the same atomic batch as the user's writes, so the
|
||||
-- assigned seq exactly mirrors the commit number. See plan:
|
||||
-- core/plans/platform/08_EXEC_AND_PUBLISH.md
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS namespace_publish_seq (
|
||||
namespace TEXT PRIMARY KEY,
|
||||
next_seq BIGINT NOT NULL DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
18
core/migrations/025_persistent_ws.sql
Normal file
18
core/migrations/025_persistent_ws.sql
Normal file
@ -0,0 +1,18 @@
|
||||
-- =============================================================================
|
||||
-- 025_persistent_ws.sql
|
||||
--
|
||||
-- Persistent WebSocket function settings — see plan
|
||||
-- core/plans/platform/06_PERSISTENT_WS_FUNCTIONS.md
|
||||
--
|
||||
-- When ws_persistent is true, the function is bound to a single WebSocket
|
||||
-- connection for its lifetime; exports ws_open / ws_frame / ws_close instead
|
||||
-- of the default _start. See pkg/serverless/persistent for runtime details.
|
||||
--
|
||||
-- All defaults are zero / false → backward compatible: existing functions
|
||||
-- continue to use the per-frame stateless WS model.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE functions ADD COLUMN ws_persistent BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE functions ADD COLUMN ws_idle_timeout_sec INTEGER DEFAULT 0;
|
||||
ALTER TABLE functions ADD COLUMN ws_max_frame_bytes INTEGER DEFAULT 0;
|
||||
ALTER TABLE functions ADD COLUMN ws_max_inflight_per_conn INTEGER DEFAULT 0;
|
||||
26
core/migrations/026_namespace_push_config.sql
Normal file
26
core/migrations/026_namespace_push_config.sql
Normal 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
194
core/migrations/contract.go
Normal 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
|
||||
}
|
||||
231
core/migrations/contract_test.go
Normal file
231
core/migrations/contract_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
237
core/migrations/roundtrip_test.go
Normal file
237
core/migrations/roundtrip_test.go
Normal 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
|
||||
}
|
||||
@ -3,54 +3,58 @@ package auth
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/rwagent"
|
||||
"github.com/DeBrosOfficial/network/pkg/tlsutil"
|
||||
)
|
||||
|
||||
// IsRootWalletInstalled checks if the `rw` CLI is available in PATH
|
||||
// IsRootWalletInstalled checks if the rootwallet agent is reachable.
|
||||
func IsRootWalletInstalled() bool {
|
||||
_, err := exec.LookPath("rw")
|
||||
return err == nil
|
||||
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
return client.IsRunning(ctx)
|
||||
}
|
||||
|
||||
// getRootWalletAddress gets the EVM address from the RootWallet keystore
|
||||
// getRootWalletAddress gets the EVM address from the rootwallet agent.
|
||||
func getRootWalletAddress() (string, error) {
|
||||
cmd := exec.Command("rw", "address", "--chain", "evm")
|
||||
cmd.Stderr = os.Stderr
|
||||
out, err := cmd.Output()
|
||||
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
data, err := client.GetAddress(ctx, "evm")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get address from rw: %w", err)
|
||||
return "", fmt.Errorf("failed to get address from rootwallet agent: %w", err)
|
||||
}
|
||||
addr := strings.TrimSpace(string(out))
|
||||
if addr == "" {
|
||||
return "", fmt.Errorf("rw returned empty address — run 'rw init' first")
|
||||
if data.Address == "" {
|
||||
return "", fmt.Errorf("rootwallet agent returned empty address")
|
||||
}
|
||||
return addr, nil
|
||||
return data.Address, nil
|
||||
}
|
||||
|
||||
// signWithRootWallet signs a message using RootWallet's EVM key.
|
||||
// Stdin is passed through so the user can enter their password if the session is expired.
|
||||
// signWithRootWallet signs a message using the rootwallet agent's EVM key.
|
||||
// The desktop app may prompt the user for approval.
|
||||
func signWithRootWallet(message string) (string, error) {
|
||||
cmd := exec.Command("rw", "sign", message, "--chain", "evm")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stderr = os.Stderr
|
||||
out, err := cmd.Output()
|
||||
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
data, err := client.Sign(ctx, message, "evm")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign with rw: %w", err)
|
||||
return "", fmt.Errorf("failed to sign with rootwallet agent: %w", err)
|
||||
}
|
||||
sig := strings.TrimSpace(string(out))
|
||||
if sig == "" {
|
||||
return "", fmt.Errorf("rw returned empty signature")
|
||||
if data.Signature == "" {
|
||||
return "", fmt.Errorf("rootwallet agent returned empty signature")
|
||||
}
|
||||
return sig, nil
|
||||
return data.Signature, nil
|
||||
}
|
||||
|
||||
// PerformRootWalletAuthentication performs a challenge-response authentication flow
|
||||
|
||||
@ -157,6 +157,7 @@ func (b *Builder) buildOramaBinaries() error {
|
||||
{Name: "identity", Package: "./cmd/identity/"},
|
||||
{Name: "sfu", Package: "./cmd/sfu/"},
|
||||
{Name: "turn", Package: "./cmd/turn/"},
|
||||
{Name: "orama-sni-router", Package: "./cmd/sni-router/"},
|
||||
}
|
||||
|
||||
for _, bin := range binaries {
|
||||
@ -197,8 +198,8 @@ func (b *Builder) buildVaultGuardian() error {
|
||||
return fmt.Errorf("zig not found in PATH — install from https://ziglang.org/download/")
|
||||
}
|
||||
|
||||
// Vault source is sibling to orama project
|
||||
vaultDir := filepath.Join(b.projectDir, "..", "orama-vault")
|
||||
// Vault source is sibling to core/ within the orama monorepo
|
||||
vaultDir := filepath.Join(b.projectDir, "..", "vault")
|
||||
if _, err := os.Stat(filepath.Join(vaultDir, "build.zig")); err != nil {
|
||||
return fmt.Errorf("vault source not found at %s — expected orama-vault as sibling directory: %w", vaultDir, err)
|
||||
}
|
||||
|
||||
116
core/pkg/cli/cmd/node/migrate_conf.go
Normal file
116
core/pkg/cli/cmd/node/migrate_conf.go
Normal file
@ -0,0 +1,116 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/auth"
|
||||
"github.com/DeBrosOfficial/network/pkg/cli"
|
||||
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var migrateConfEnv string
|
||||
|
||||
var migrateConfCmd = &cobra.Command{
|
||||
Use: "migrate-conf",
|
||||
Short: "Register nodes.conf nodes with your wallet",
|
||||
Long: `One-time migration: reads nodes from nodes.conf for an environment
|
||||
and registers each with your wallet via the gateway API. After migration,
|
||||
these nodes will appear in 'orama nodes' output.
|
||||
|
||||
Requires: orama auth login (for API authentication)`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
env := migrateConfEnv
|
||||
if env == "" {
|
||||
active, err := cli.GetActiveEnvironment()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get active environment: %w", err)
|
||||
}
|
||||
env = active.Name
|
||||
}
|
||||
|
||||
// Load nodes from nodes.conf
|
||||
nodes, err := remotessh.LoadEnvNodes(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load nodes.conf: %w", err)
|
||||
}
|
||||
|
||||
// Get gateway URL
|
||||
envConfig, err := cli.GetEnvironmentByName(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("environment %q not configured: %w", env, err)
|
||||
}
|
||||
|
||||
// Load stored credentials
|
||||
store, err := auth.LoadEnhancedCredentials()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load credentials: %w", err)
|
||||
}
|
||||
creds := store.GetDefaultCredential(envConfig.GatewayURL)
|
||||
if creds == nil || creds.APIKey == "" {
|
||||
return fmt.Errorf("no credentials for %s — run 'orama auth login' first", envConfig.GatewayURL)
|
||||
}
|
||||
|
||||
if len(nodes) == 0 {
|
||||
fmt.Printf("No nodes found for environment %q in nodes.conf\n", env)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Migrating %d node(s) from nodes.conf to %s...\n\n", len(nodes), env)
|
||||
|
||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||
registered := 0
|
||||
|
||||
for _, n := range nodes {
|
||||
body := map[string]string{
|
||||
"ip_address": n.Host,
|
||||
"environment": env,
|
||||
"role": n.Role,
|
||||
"ssh_user": n.User,
|
||||
}
|
||||
payload, _ := json.Marshal(body)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost,
|
||||
envConfig.GatewayURL+"/v1/operator/node/register",
|
||||
bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), " %s: failed to create request: %v\n", n.Host, err)
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-API-Key", creds.APIKey)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), " %s: request failed: %v\n", n.Host, err)
|
||||
continue
|
||||
}
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
fmt.Printf(" %s (%s): registered\n", n.Host, n.Role)
|
||||
registered++
|
||||
} else if resp.StatusCode == http.StatusNotFound {
|
||||
fmt.Printf(" %s: not found in cluster (node may not have joined yet)\n", n.Host)
|
||||
} else {
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), " %s: HTTP %d: %s\n", n.Host, resp.StatusCode, string(respBody))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n%d/%d nodes registered with your wallet\n", registered, len(nodes))
|
||||
if registered < len(nodes) {
|
||||
fmt.Println("Nodes not found may need to join the cluster first, then re-run this command.")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrateConfCmd.Flags().StringVar(&migrateConfEnv, "env", "", "Environment to migrate (default: active)")
|
||||
}
|
||||
@ -32,4 +32,7 @@ func init() {
|
||||
Cmd.AddCommand(recoverRaftCmd)
|
||||
Cmd.AddCommand(enrollCmd)
|
||||
Cmd.AddCommand(unlockCmd)
|
||||
Cmd.AddCommand(migrateConfCmd)
|
||||
Cmd.AddCommand(setupCmd)
|
||||
Cmd.AddCommand(schemaCmd)
|
||||
}
|
||||
|
||||
264
core/pkg/cli/cmd/node/schema.go
Normal file
264
core/pkg/cli/cmd/node/schema.go
Normal 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)
|
||||
}
|
||||
47
core/pkg/cli/cmd/node/setup.go
Normal file
47
core/pkg/cli/cmd/node/setup.go
Normal file
@ -0,0 +1,47 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"github.com/DeBrosOfficial/network/pkg/cli/production/setup"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var setupOpts setup.Options
|
||||
|
||||
var setupCmd = &cobra.Command{
|
||||
Use: "setup",
|
||||
Short: "Set up a fresh VPS as an Orama node",
|
||||
Long: `Bootstrap a fresh VPS into a running Orama node in one command.
|
||||
|
||||
Creates an SSH key in rootwallet, installs it on the VPS, uploads the binary
|
||||
archive, and runs the node install. For the first node, use --genesis to
|
||||
create a new cluster.
|
||||
|
||||
Examples:
|
||||
# Genesis node (first node, creates new cluster)
|
||||
orama node setup --ip 1.2.3.4 --password 'vps-pass' --env devnet \
|
||||
--base-domain orama-devnet.network --role nameserver --genesis
|
||||
|
||||
# Join existing cluster
|
||||
orama node setup --ip 5.6.7.8 --password 'vps-pass' --env devnet \
|
||||
--base-domain orama-devnet.network
|
||||
|
||||
# Join as nameserver
|
||||
orama node setup --ip 9.10.11.12 --password 'vps-pass' --env devnet \
|
||||
--base-domain orama-devnet.network --role nameserver`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return setup.Run(setupOpts)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
setupCmd.Flags().StringVar(&setupOpts.IP, "ip", "", "Public IP address of the VPS (required)")
|
||||
setupCmd.Flags().StringVar(&setupOpts.Env, "env", "", "Target environment (default: active)")
|
||||
setupCmd.Flags().StringVar(&setupOpts.Role, "role", "node", "Node role: node or nameserver")
|
||||
setupCmd.Flags().StringVar(&setupOpts.User, "user", "root", "SSH user on the VPS")
|
||||
setupCmd.Flags().StringVar(&setupOpts.Password, "password", "", "One-time password for initial SSH access")
|
||||
setupCmd.Flags().StringVar(&setupOpts.BaseDomain, "base-domain", "", "Base domain for the network")
|
||||
setupCmd.Flags().StringVar(&setupOpts.Gateway, "gateway", "", "Gateway URL for invite tokens (e.g., http://1.2.3.4)")
|
||||
setupCmd.Flags().BoolVar(&setupOpts.Genesis, "genesis", false, "Create a new cluster (first node)")
|
||||
setupCmd.Flags().BoolVar(&setupOpts.AnyoneRelay, "anyone-relay", false, "Run as Anyone relay operator")
|
||||
setupCmd.MarkFlagRequired("ip")
|
||||
}
|
||||
58
core/pkg/cli/cmd/nodescmd/nodes.go
Normal file
58
core/pkg/cli/cmd/nodescmd/nodes.go
Normal file
@ -0,0 +1,58 @@
|
||||
package nodescmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/cli"
|
||||
"github.com/DeBrosOfficial/network/pkg/cli/noderesolver"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var envFlag string
|
||||
|
||||
// Cmd is the top-level "nodes" command — lists operator's nodes.
|
||||
var Cmd = &cobra.Command{
|
||||
Use: "nodes",
|
||||
Short: "List your nodes across environments",
|
||||
Long: `List all nodes owned by your wallet. Queries the network API
|
||||
with your stored credentials, falling back to nodes.conf.
|
||||
|
||||
Requires: orama auth login (for API-based resolution)`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
env := envFlag
|
||||
if env == "" {
|
||||
active, err := cli.GetActiveEnvironment()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get active environment: %w", err)
|
||||
}
|
||||
env = active.Name
|
||||
}
|
||||
|
||||
nodes, err := noderesolver.ResolveNodes(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve nodes: %w", err)
|
||||
}
|
||||
|
||||
if len(nodes) == 0 {
|
||||
fmt.Printf("No nodes found for environment %q\n", env)
|
||||
fmt.Println("Register nodes with: orama node setup <ip> --env", env)
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "IP\tROLE\tUSER\tENVIRONMENT\n")
|
||||
for _, n := range nodes {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", n.Host, n.Role, n.User, n.Environment)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
fmt.Printf("\n%d node(s) in %s\n", len(nodes), env)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Cmd.Flags().StringVar(&envFlag, "env", "", "Filter by environment (default: active environment)")
|
||||
}
|
||||
259
core/pkg/cli/cmd/pushcmd/push.go
Normal file
259
core/pkg/cli/cmd/pushcmd/push.go
Normal 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)
|
||||
}
|
||||
234
core/pkg/cli/cmd/rolloutcmd/rollout.go
Normal file
234
core/pkg/cli/cmd/rolloutcmd/rollout.go
Normal file
@ -0,0 +1,234 @@
|
||||
package rolloutcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/cli"
|
||||
"github.com/DeBrosOfficial/network/pkg/cli/noderesolver"
|
||||
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
envFlag string
|
||||
delaySec int
|
||||
)
|
||||
|
||||
// Cmd is the top-level "rollout" command — build + push + rolling upgrade.
|
||||
var Cmd = &cobra.Command{
|
||||
Use: "rollout",
|
||||
Short: "Rolling upgrade of your nodes",
|
||||
Long: `Build, push, and perform a rolling upgrade on all your nodes in an environment.
|
||||
Upgrades followers first, leader last, with health checks between each node.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
env := envFlag
|
||||
if env == "" {
|
||||
active, err := cli.GetActiveEnvironment()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get active environment: %w", err)
|
||||
}
|
||||
env = active.Name
|
||||
}
|
||||
|
||||
nodes, err := noderesolver.ResolveNodes(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve nodes: %w", err)
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
return fmt.Errorf("no nodes found for environment %q", env)
|
||||
}
|
||||
|
||||
cleanup, err := remotessh.PrepareNodeKeys(nodes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare SSH keys: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
fmt.Printf("Rolling out to %d node(s) in %s\n\n", len(nodes), env)
|
||||
|
||||
// Step 1: Find archive
|
||||
archivePath := findNewestArchive()
|
||||
if archivePath == "" {
|
||||
return fmt.Errorf("no binary archive found in /tmp/ (run `orama build` first)")
|
||||
}
|
||||
info, err := os.Stat(archivePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat archive %s: %w", archivePath, err)
|
||||
}
|
||||
fmt.Printf("Archive: %s (%s)\n\n", filepath.Base(archivePath), formatBytes(info.Size()))
|
||||
|
||||
// Step 2: Push archive to all nodes
|
||||
fmt.Println("Pushing archive to all nodes...")
|
||||
if err := pushArchive(nodes, archivePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Rolling upgrade — followers first, leader last
|
||||
fmt.Println("\nRolling upgrade (followers first, leader last)...")
|
||||
|
||||
leaderIdx := findLeaderIndex(nodes)
|
||||
if leaderIdx < 0 {
|
||||
fmt.Fprintf(os.Stderr, " Warning: could not detect RQLite leader, upgrading in order\n")
|
||||
}
|
||||
|
||||
// Determine SSH options based on environment
|
||||
var sshOpts []remotessh.SSHOption
|
||||
if env == "sandbox" {
|
||||
sshOpts = append(sshOpts, remotessh.WithNoHostKeyCheck())
|
||||
}
|
||||
|
||||
delay := time.Duration(delaySec) * time.Second
|
||||
|
||||
// Upgrade non-leaders first
|
||||
count := 0
|
||||
for i := range nodes {
|
||||
if i == leaderIdx {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if err := upgradeNode(nodes[i], count, len(nodes), sshOpts); err != nil {
|
||||
return err
|
||||
}
|
||||
if count < len(nodes) {
|
||||
fmt.Printf(" Waiting %s before next node...\n", delay)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Upgrade leader last
|
||||
if leaderIdx >= 0 {
|
||||
count++
|
||||
if err := upgradeNode(nodes[leaderIdx], count, len(nodes), sshOpts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\nRollout complete for %s (%d nodes)\n", env, len(nodes))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Cmd.Flags().StringVar(&envFlag, "env", "", "Environment (default: active)")
|
||||
Cmd.Flags().IntVar(&delaySec, "delay", 30, "Seconds to wait between node upgrades")
|
||||
}
|
||||
|
||||
// findLeaderIndex returns the index of the RQLite leader, or -1 if unknown.
|
||||
func findLeaderIndex(nodes []inspector.Node) int {
|
||||
for i, n := range nodes {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
result := inspector.RunSSH(ctx, n, "curl -sf http://localhost:5001/status 2>/dev/null | grep -o '\"state\":\"[^\"]*\"'")
|
||||
cancel()
|
||||
if result.OK() && strings.Contains(result.Stdout, "Leader") {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// upgradeNode performs orama node upgrade --restart on a single node.
|
||||
func upgradeNode(node inspector.Node, current, total int, sshOpts []remotessh.SSHOption) error {
|
||||
fmt.Printf(" [%d/%d] Upgrading %s...\n", current, total, node.Host)
|
||||
|
||||
// Pre-replace orama CLI binary to avoid ETXTBSY
|
||||
preReplace := "rm -f /usr/local/bin/orama && cp /opt/orama/bin/orama /usr/local/bin/orama"
|
||||
if err := remotessh.RunSSHStreaming(node, preReplace, sshOpts...); err != nil {
|
||||
return fmt.Errorf("pre-replace orama binary on %s: %w", node.Host, err)
|
||||
}
|
||||
|
||||
if err := remotessh.RunSSHStreaming(node, "orama node upgrade --restart", sshOpts...); err != nil {
|
||||
return fmt.Errorf("upgrade %s: %w", node.Host, err)
|
||||
}
|
||||
|
||||
// Wait for health
|
||||
fmt.Printf(" Checking health...")
|
||||
if err := waitForHealth(node, 2*time.Minute); err != nil {
|
||||
fmt.Printf(" WARN: %v\n", err)
|
||||
} else {
|
||||
fmt.Println(" OK")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pushArchive uploads the archive to the first node, then fans out server-to-server.
|
||||
func pushArchive(nodes []inspector.Node, archivePath string) error {
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
remotePath := "/tmp/" + filepath.Base(archivePath)
|
||||
|
||||
// Upload to first node
|
||||
hub := nodes[0]
|
||||
fmt.Printf(" Uploading to %s...\n", hub.Host)
|
||||
if err := remotessh.UploadFile(hub, archivePath, remotePath); err != nil {
|
||||
return fmt.Errorf("upload to %s: %w", hub.Host, err)
|
||||
}
|
||||
|
||||
// Extract on hub
|
||||
extractCmd := fmt.Sprintf("mkdir -p /opt/orama && tar xzf %s -C /opt/orama && rm -f %s", remotePath, remotePath)
|
||||
if err := remotessh.RunSSHStreaming(hub, extractCmd); err != nil {
|
||||
return fmt.Errorf("extract on %s: %w", hub.Host, err)
|
||||
}
|
||||
|
||||
// For remaining nodes, upload directly and extract
|
||||
for _, n := range nodes[1:] {
|
||||
fmt.Printf(" Uploading to %s...\n", n.Host)
|
||||
if err := remotessh.UploadFile(n, archivePath, remotePath); err != nil {
|
||||
return fmt.Errorf("upload to %s: %w", n.Host, err)
|
||||
}
|
||||
if err := remotessh.RunSSHStreaming(n, extractCmd); err != nil {
|
||||
return fmt.Errorf("extract on %s: %w", n.Host, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForHealth polls RQLite health on a node until it reaches Leader or Follower state.
|
||||
func waitForHealth(node inspector.Node, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
result := inspector.RunSSH(ctx, node, "curl -sf http://localhost:5001/status 2>/dev/null | grep -o '\"state\":\"[^\"]*\"'")
|
||||
cancel()
|
||||
if result.OK() && (strings.Contains(result.Stdout, "Leader") || strings.Contains(result.Stdout, "Follower")) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("timed out waiting for healthy state on %s", node.Host)
|
||||
}
|
||||
|
||||
// findNewestArchive finds the newest orama binary archive in /tmp/.
|
||||
func findNewestArchive() string {
|
||||
matches, err := filepath.Glob("/tmp/orama-*-linux-*.tar.gz")
|
||||
if err != nil || len(matches) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
fi, _ := os.Stat(matches[i])
|
||||
fj, _ := os.Stat(matches[j])
|
||||
if fi == nil || fj == nil {
|
||||
return false
|
||||
}
|
||||
return fi.ModTime().After(fj.ModTime())
|
||||
})
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
func formatBytes(b int64) string {
|
||||
const mb = 1024 * 1024
|
||||
if b >= mb {
|
||||
return fmt.Sprintf("%.1f MB", float64(b)/float64(mb))
|
||||
}
|
||||
return fmt.Sprintf("%d KB", b/1024)
|
||||
}
|
||||
101
core/pkg/cli/cmd/sshcmd/ssh.go
Normal file
101
core/pkg/cli/cmd/sshcmd/ssh.go
Normal file
@ -0,0 +1,101 @@
|
||||
package sshcmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/cli"
|
||||
"github.com/DeBrosOfficial/network/pkg/cli/noderesolver"
|
||||
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var envFlag string
|
||||
|
||||
// Cmd is the top-level "ssh" command — SSH into any node by IP or hostname.
|
||||
var Cmd = &cobra.Command{
|
||||
Use: "ssh <ip-or-hostname> [-- command]",
|
||||
Short: "SSH into a node",
|
||||
Long: `SSH into a node by IP address or hostname.
|
||||
Resolves the SSH key from rootwallet automatically.
|
||||
|
||||
Pass a command after the IP to run it non-interactively:
|
||||
orama ssh 1.2.3.4 'sudo systemctl status orama-node'`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
DisableFlagParsing: false,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target := args[0]
|
||||
remoteCmd := ""
|
||||
if len(args) > 1 {
|
||||
remoteCmd = args[1]
|
||||
}
|
||||
|
||||
env := envFlag
|
||||
if env == "" {
|
||||
active, err := cli.GetActiveEnvironment()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get active environment: %w", err)
|
||||
}
|
||||
env = active.Name
|
||||
}
|
||||
|
||||
// Resolve nodes to find the target
|
||||
nodes, err := noderesolver.ResolveNodes(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve nodes: %w", err)
|
||||
}
|
||||
|
||||
// Match by IP
|
||||
for _, n := range nodes {
|
||||
if n.Host == target {
|
||||
return sshInto(n, remoteCmd)
|
||||
}
|
||||
}
|
||||
|
||||
// Not found — try direct SSH with default vault target
|
||||
fmt.Printf("Node %q not found in %s nodes, attempting direct SSH...\n", target, env)
|
||||
return sshInto(inspector.Node{
|
||||
Host: target,
|
||||
User: "root",
|
||||
VaultTarget: target + "/root",
|
||||
}, remoteCmd)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Cmd.Flags().StringVar(&envFlag, "env", "", "Environment to search (default: active)")
|
||||
}
|
||||
|
||||
func sshInto(node inspector.Node, remoteCmd string) error {
|
||||
nodes := []inspector.Node{node}
|
||||
cleanup, err := remotessh.PrepareNodeKeys(nodes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve SSH key: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
keyPath := nodes[0].SSHKey
|
||||
|
||||
sshBin, err := exec.LookPath("ssh")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ssh not found in PATH: %w", err)
|
||||
}
|
||||
|
||||
sshArgs := []string{
|
||||
"-i", keyPath,
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
fmt.Sprintf("%s@%s", node.User, node.Host),
|
||||
}
|
||||
if remoteCmd != "" {
|
||||
sshArgs = append(sshArgs, remoteCmd)
|
||||
}
|
||||
|
||||
sshCmd := exec.Command(sshBin, sshArgs...)
|
||||
sshCmd.Stdin = os.Stdin
|
||||
sshCmd.Stdout = os.Stdout
|
||||
sshCmd.Stderr = os.Stderr
|
||||
return sshCmd.Run()
|
||||
}
|
||||
143
core/pkg/cli/cmd/statuscmd/status.go
Normal file
143
core/pkg/cli/cmd/statuscmd/status.go
Normal file
@ -0,0 +1,143 @@
|
||||
package statuscmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/cli"
|
||||
"github.com/DeBrosOfficial/network/pkg/cli/noderesolver"
|
||||
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
envFlag string
|
||||
jsonFlag bool
|
||||
)
|
||||
|
||||
// Cmd is the top-level "status" command — health check for operator's nodes.
|
||||
var Cmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show health status of your nodes",
|
||||
Long: `Check the health of all your nodes in an environment.
|
||||
SSHes into each node and runs orama node report to collect health data.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
env := envFlag
|
||||
if env == "" {
|
||||
active, err := cli.GetActiveEnvironment()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get active environment: %w", err)
|
||||
}
|
||||
env = active.Name
|
||||
}
|
||||
|
||||
nodes, err := noderesolver.ResolveNodes(env)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve nodes: %w", err)
|
||||
}
|
||||
|
||||
if len(nodes) == 0 {
|
||||
fmt.Printf("No nodes found for environment %q\n", env)
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanup, err := remotessh.PrepareNodeKeys(nodes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare SSH keys: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
fmt.Printf("Checking %d node(s) in %s...\n\n", len(nodes), env)
|
||||
|
||||
type nodeResult struct {
|
||||
Host string `json:"host"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
results := make([]nodeResult, len(nodes))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, n := range nodes {
|
||||
wg.Add(1)
|
||||
go func(idx int, node inspector.Node) {
|
||||
defer wg.Done()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result := inspector.RunSSH(ctx, node, "sudo orama node report --json")
|
||||
nr := nodeResult{Host: node.Host, Role: node.Role}
|
||||
|
||||
if !result.OK() {
|
||||
nr.Status = "unreachable"
|
||||
nr.Error = fmt.Sprintf("SSH failed (exit %d)", result.ExitCode)
|
||||
if result.Stderr != "" {
|
||||
nr.Error = result.Stderr
|
||||
if len(nr.Error) > 100 {
|
||||
nr.Error = nr.Error[:100] + "..."
|
||||
}
|
||||
}
|
||||
results[idx] = nr
|
||||
return
|
||||
}
|
||||
|
||||
var report struct {
|
||||
Gateway struct {
|
||||
Responsive bool `json:"responsive"`
|
||||
} `json:"gateway"`
|
||||
RQLite struct {
|
||||
RaftState string `json:"raft_state"`
|
||||
} `json:"rqlite"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result.Stdout), &report); err != nil {
|
||||
nr.Status = "unknown"
|
||||
nr.Error = "failed to parse report"
|
||||
results[idx] = nr
|
||||
return
|
||||
}
|
||||
|
||||
if report.Gateway.Responsive && (report.RQLite.RaftState == "Leader" || report.RQLite.RaftState == "Follower") {
|
||||
nr.Status = "healthy"
|
||||
} else {
|
||||
nr.Status = "degraded"
|
||||
}
|
||||
results[idx] = nr
|
||||
}(i, n)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if jsonFlag {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(results)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "IP\tROLE\tSTATUS\tDETAILS\n")
|
||||
healthy := 0
|
||||
for _, r := range results {
|
||||
details := r.Error
|
||||
if r.Status == "healthy" {
|
||||
healthy++
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Host, r.Role, r.Status, details)
|
||||
}
|
||||
w.Flush()
|
||||
|
||||
fmt.Printf("\n%d/%d nodes healthy\n", healthy, len(results))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
Cmd.Flags().StringVar(&envFlag, "env", "", "Environment (default: active)")
|
||||
Cmd.Flags().BoolVar(&jsonFlag, "json", false, "Output as JSON")
|
||||
}
|
||||
@ -164,30 +164,8 @@ func handleEnvAdd(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
envConfig, err := LoadEnvironmentConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Failed to load environment config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Check if environment already exists
|
||||
for _, env := range envConfig.Environments {
|
||||
if env.Name == name {
|
||||
fmt.Fprintf(os.Stderr, "❌ Environment '%s' already exists\n", name)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new environment
|
||||
envConfig.Environments = append(envConfig.Environments, Environment{
|
||||
Name: name,
|
||||
GatewayURL: gatewayURL,
|
||||
Description: description,
|
||||
IsActive: false,
|
||||
})
|
||||
|
||||
if err := SaveEnvironmentConfig(envConfig); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Failed to save environment config: %v\n", err)
|
||||
if err := AddEnvironment(name, gatewayURL, description); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Failed to add environment: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@ -206,37 +184,8 @@ func handleEnvRemove(args []string) {
|
||||
|
||||
name := args[0]
|
||||
|
||||
envConfig, err := LoadEnvironmentConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Failed to load environment config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Find and remove environment
|
||||
found := false
|
||||
newEnvs := make([]Environment, 0, len(envConfig.Environments))
|
||||
for _, env := range envConfig.Environments {
|
||||
if env.Name == name {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
newEnvs = append(newEnvs, env)
|
||||
}
|
||||
|
||||
if !found {
|
||||
fmt.Fprintf(os.Stderr, "❌ Environment '%s' not found\n", name)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
envConfig.Environments = newEnvs
|
||||
|
||||
// If we removed the active environment, switch to devnet
|
||||
if envConfig.ActiveEnvironment == name {
|
||||
envConfig.ActiveEnvironment = "devnet"
|
||||
}
|
||||
|
||||
if err := SaveEnvironmentConfig(envConfig); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Failed to save environment config: %v\n", err)
|
||||
if err := RemoveEnvironment(name); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Failed to remove environment: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
||||
@ -45,8 +45,11 @@ var DefaultEnvironments = []Environment{
|
||||
},
|
||||
}
|
||||
|
||||
// GetEnvironmentConfigPath returns the path to the environment config file
|
||||
func GetEnvironmentConfigPath() (string, error) {
|
||||
// getEnvironmentConfigPathFn is the function used to resolve the config path.
|
||||
// Tests override this to point at a temp file.
|
||||
var getEnvironmentConfigPathFn = getEnvironmentConfigPathDefault
|
||||
|
||||
func getEnvironmentConfigPathDefault() (string, error) {
|
||||
configDir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get config directory: %w", err)
|
||||
@ -54,6 +57,11 @@ func GetEnvironmentConfigPath() (string, error) {
|
||||
return filepath.Join(configDir, "environments.json"), nil
|
||||
}
|
||||
|
||||
// GetEnvironmentConfigPath returns the path to the environment config file
|
||||
func GetEnvironmentConfigPath() (string, error) {
|
||||
return getEnvironmentConfigPathFn()
|
||||
}
|
||||
|
||||
// LoadEnvironmentConfig loads the environment configuration
|
||||
func LoadEnvironmentConfig() (*EnvironmentConfig, error) {
|
||||
path, err := GetEnvironmentConfigPath()
|
||||
@ -170,6 +178,63 @@ func GetEnvironmentByName(name string) (*Environment, error) {
|
||||
return nil, fmt.Errorf("environment '%s' not found", name)
|
||||
}
|
||||
|
||||
// AddEnvironment adds a new environment or updates an existing one.
|
||||
// If an environment with the same name already exists, its gateway URL and
|
||||
// description are updated in place.
|
||||
func AddEnvironment(name, gatewayURL, description string) error {
|
||||
envConfig, err := LoadEnvironmentConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, env := range envConfig.Environments {
|
||||
if env.Name == name {
|
||||
envConfig.Environments[i].GatewayURL = gatewayURL
|
||||
envConfig.Environments[i].Description = description
|
||||
return SaveEnvironmentConfig(envConfig)
|
||||
}
|
||||
}
|
||||
|
||||
envConfig.Environments = append(envConfig.Environments, Environment{
|
||||
Name: name,
|
||||
GatewayURL: gatewayURL,
|
||||
Description: description,
|
||||
})
|
||||
|
||||
return SaveEnvironmentConfig(envConfig)
|
||||
}
|
||||
|
||||
// RemoveEnvironment removes an environment by name. If the removed environment
|
||||
// was active, the active environment falls back to "devnet".
|
||||
func RemoveEnvironment(name string) error {
|
||||
envConfig, err := LoadEnvironmentConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newEnvs := make([]Environment, 0, len(envConfig.Environments))
|
||||
found := false
|
||||
for _, env := range envConfig.Environments {
|
||||
if env.Name == name {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
newEnvs = append(newEnvs, env)
|
||||
}
|
||||
|
||||
if !found {
|
||||
return nil // already absent, nothing to do
|
||||
}
|
||||
|
||||
envConfig.Environments = newEnvs
|
||||
|
||||
if envConfig.ActiveEnvironment == name {
|
||||
envConfig.ActiveEnvironment = "devnet"
|
||||
}
|
||||
|
||||
return SaveEnvironmentConfig(envConfig)
|
||||
}
|
||||
|
||||
// InitializeEnvironments initializes the environment config with defaults
|
||||
func InitializeEnvironments() error {
|
||||
path, err := GetEnvironmentConfigPath()
|
||||
|
||||
131
core/pkg/cli/environment_test.go
Normal file
131
core/pkg/cli/environment_test.go
Normal file
@ -0,0 +1,131 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeTestConfig writes an EnvironmentConfig to a temp file and returns
|
||||
// a helper that patches GetEnvironmentConfigPath to return that path.
|
||||
// The returned cleanup restores the original function.
|
||||
func writeTestConfig(t *testing.T, cfg *EnvironmentConfig) func() {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "envconfig-*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("create temp file: %v", err)
|
||||
}
|
||||
data, _ := json.MarshalIndent(cfg, "", " ")
|
||||
if _, err := f.Write(data); err != nil {
|
||||
t.Fatalf("write temp file: %v", err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
origFn := getEnvironmentConfigPathFn
|
||||
getEnvironmentConfigPathFn = func() (string, error) { return f.Name(), nil }
|
||||
return func() { getEnvironmentConfigPathFn = origFn }
|
||||
}
|
||||
|
||||
func defaultTestConfig() *EnvironmentConfig {
|
||||
return &EnvironmentConfig{
|
||||
Environments: []Environment{
|
||||
{Name: "sandbox", GatewayURL: "https://dbrs.space", Description: "Sandbox cluster"},
|
||||
{Name: "devnet", GatewayURL: "https://orama-devnet.network", Description: "Development network"},
|
||||
{Name: "testnet", GatewayURL: "https://orama-testnet.network", Description: "Test network"},
|
||||
},
|
||||
ActiveEnvironment: "sandbox",
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddEnvironment_new(t *testing.T) {
|
||||
cleanup := writeTestConfig(t, defaultTestConfig())
|
||||
defer cleanup()
|
||||
|
||||
if err := AddEnvironment("staging", "https://staging.example.com", "Staging env"); err != nil {
|
||||
t.Fatalf("AddEnvironment: %v", err)
|
||||
}
|
||||
|
||||
env, err := GetEnvironmentByName("staging")
|
||||
if err != nil {
|
||||
t.Fatalf("GetEnvironmentByName: %v", err)
|
||||
}
|
||||
if env.GatewayURL != "https://staging.example.com" {
|
||||
t.Errorf("GatewayURL = %q, want %q", env.GatewayURL, "https://staging.example.com")
|
||||
}
|
||||
if env.Description != "Staging env" {
|
||||
t.Errorf("Description = %q, want %q", env.Description, "Staging env")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddEnvironment_update(t *testing.T) {
|
||||
cleanup := writeTestConfig(t, defaultTestConfig())
|
||||
defer cleanup()
|
||||
|
||||
if err := AddEnvironment("sandbox", "https://new.example.com", "Updated sandbox"); err != nil {
|
||||
t.Fatalf("AddEnvironment: %v", err)
|
||||
}
|
||||
|
||||
env, err := GetEnvironmentByName("sandbox")
|
||||
if err != nil {
|
||||
t.Fatalf("GetEnvironmentByName: %v", err)
|
||||
}
|
||||
if env.GatewayURL != "https://new.example.com" {
|
||||
t.Errorf("GatewayURL = %q, want %q", env.GatewayURL, "https://new.example.com")
|
||||
}
|
||||
if env.Description != "Updated sandbox" {
|
||||
t.Errorf("Description = %q, want %q", env.Description, "Updated sandbox")
|
||||
}
|
||||
|
||||
// Verify upsert didn't create a duplicate
|
||||
cfg, _ := LoadEnvironmentConfig()
|
||||
count := 0
|
||||
for _, e := range cfg.Environments {
|
||||
if e.Name == "sandbox" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("sandbox entries = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveEnvironment_existing(t *testing.T) {
|
||||
cleanup := writeTestConfig(t, defaultTestConfig())
|
||||
defer cleanup()
|
||||
|
||||
if err := RemoveEnvironment("testnet"); err != nil {
|
||||
t.Fatalf("RemoveEnvironment: %v", err)
|
||||
}
|
||||
|
||||
_, err := GetEnvironmentByName("testnet")
|
||||
if err == nil {
|
||||
t.Error("expected error for removed environment, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveEnvironment_absent(t *testing.T) {
|
||||
cleanup := writeTestConfig(t, defaultTestConfig())
|
||||
defer cleanup()
|
||||
|
||||
if err := RemoveEnvironment("nonexistent"); err != nil {
|
||||
t.Errorf("RemoveEnvironment(absent) = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveEnvironment_active_falls_back(t *testing.T) {
|
||||
cleanup := writeTestConfig(t, defaultTestConfig())
|
||||
defer cleanup()
|
||||
|
||||
if err := RemoveEnvironment("sandbox"); err != nil {
|
||||
t.Fatalf("RemoveEnvironment: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadEnvironmentConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEnvironmentConfig: %v", err)
|
||||
}
|
||||
if cfg.ActiveEnvironment != "devnet" {
|
||||
t.Errorf("ActiveEnvironment = %q, want %q", cfg.ActiveEnvironment, "devnet")
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,14 @@ type FunctionConfig struct {
|
||||
Timeout int `yaml:"timeout"`
|
||||
Retry RetryConfig `yaml:"retry"`
|
||||
Env map[string]string `yaml:"env"`
|
||||
|
||||
// Persistent WebSocket settings — when WSPersistent is true, the function
|
||||
// must export ws_open / ws_frame / ws_close instead of running per-frame
|
||||
// stateless. See core/plans/platform/06_PERSISTENT_WS_FUNCTIONS.md.
|
||||
WSPersistent bool `yaml:"ws_persistent"`
|
||||
WSIdleTimeoutSec int `yaml:"ws_idle_timeout_sec"`
|
||||
WSMaxFrameBytes int `yaml:"ws_max_frame_bytes"`
|
||||
WSMaxInflightPerConn int `yaml:"ws_max_inflight_per_conn"`
|
||||
}
|
||||
|
||||
// RetryConfig holds retry settings.
|
||||
@ -198,11 +206,28 @@ func uploadWASMFunction(wasmPath string, cfg *FunctionConfig) (map[string]interf
|
||||
writer.WriteField("retry_count", strconv.Itoa(cfg.Retry.Count))
|
||||
writer.WriteField("retry_delay_seconds", strconv.Itoa(cfg.Retry.Delay))
|
||||
|
||||
// Add env vars as metadata JSON
|
||||
// Build metadata JSON. The deploy handler json.Unmarshal()s this into
|
||||
// FunctionDefinition first, then overlays the explicit form fields below.
|
||||
// Any field that has no explicit form-field equivalent (env vars, the
|
||||
// ws_* persistent settings) MUST live in this blob.
|
||||
metaObj := map[string]interface{}{}
|
||||
if len(cfg.Env) > 0 {
|
||||
metadata, _ := json.Marshal(map[string]interface{}{
|
||||
"env_vars": cfg.Env,
|
||||
})
|
||||
metaObj["env_vars"] = cfg.Env
|
||||
}
|
||||
if cfg.WSPersistent {
|
||||
metaObj["ws_persistent"] = true
|
||||
}
|
||||
if cfg.WSIdleTimeoutSec > 0 {
|
||||
metaObj["ws_idle_timeout_sec"] = cfg.WSIdleTimeoutSec
|
||||
}
|
||||
if cfg.WSMaxFrameBytes > 0 {
|
||||
metaObj["ws_max_frame_bytes"] = cfg.WSMaxFrameBytes
|
||||
}
|
||||
if cfg.WSMaxInflightPerConn > 0 {
|
||||
metaObj["ws_max_inflight_per_conn"] = cfg.WSMaxInflightPerConn
|
||||
}
|
||||
if len(metaObj) > 0 {
|
||||
metadata, _ := json.Marshal(metaObj)
|
||||
writer.WriteField("metadata", string(metadata))
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]
|
||||
|
||||
114
core/pkg/cli/functions/triggers_test.go
Normal file
114
core/pkg/cli/functions/triggers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -124,6 +124,7 @@ func DeriveAlerts(snap *ClusterSnapshot) []Alert {
|
||||
alerts = append(alerts, checkNodeNetwork(r, host)...)
|
||||
alerts = append(alerts, checkNodeOlric(r, host)...)
|
||||
alerts = append(alerts, checkNodeIPFS(r, host)...)
|
||||
alerts = append(alerts, checkNodeVault(r, host)...)
|
||||
alerts = append(alerts, checkNodeGateway(r, host)...)
|
||||
}
|
||||
|
||||
@ -866,6 +867,41 @@ func checkNodeIPFS(r *report.NodeReport, host string) []Alert {
|
||||
return alerts
|
||||
}
|
||||
|
||||
func checkNodeVault(r *report.NodeReport, host string) []Alert {
|
||||
if r.Vault == nil {
|
||||
return nil
|
||||
}
|
||||
var alerts []Alert
|
||||
|
||||
if !r.Vault.ServiceActive {
|
||||
alerts = append(alerts, Alert{AlertCritical, "vault", host, "Vault service not running"})
|
||||
return alerts
|
||||
}
|
||||
|
||||
if !r.Vault.Responsive {
|
||||
alerts = append(alerts, Alert{AlertWarning, "vault", host, "Vault not responding to health queries"})
|
||||
return alerts
|
||||
}
|
||||
|
||||
switch r.Vault.Status {
|
||||
case "unavailable":
|
||||
alerts = append(alerts, Alert{AlertCritical, "vault", host,
|
||||
fmt.Sprintf("Vault unavailable: %d/%d guardians healthy (need %d for reads)",
|
||||
r.Vault.Healthy, r.Vault.Guardians, r.Vault.Threshold)})
|
||||
case "degraded":
|
||||
alerts = append(alerts, Alert{AlertWarning, "vault", host,
|
||||
fmt.Sprintf("Vault degraded: %d/%d guardians healthy (need %d for writes)",
|
||||
r.Vault.Healthy, r.Vault.Guardians, r.Vault.WriteQuorum)})
|
||||
}
|
||||
|
||||
if r.Vault.RestartCount > 3 {
|
||||
alerts = append(alerts, Alert{AlertWarning, "vault", host,
|
||||
fmt.Sprintf("Vault restarted %d times", r.Vault.RestartCount)})
|
||||
}
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
func checkNodeGateway(r *report.NodeReport, host string) []Alert {
|
||||
if r.Gateway == nil {
|
||||
return nil
|
||||
|
||||
120
core/pkg/cli/monitor/alerts_vault_test.go
Normal file
120
core/pkg/cli/monitor/alerts_vault_test.go
Normal file
@ -0,0 +1,120 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/cli/production/report"
|
||||
)
|
||||
|
||||
func TestCheckNodeVault_nil(t *testing.T) {
|
||||
r := &report.NodeReport{}
|
||||
alerts := checkNodeVault(r, "10.0.0.1")
|
||||
if len(alerts) != 0 {
|
||||
t.Errorf("expected 0 alerts for nil vault, got %d", len(alerts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNodeVault_serviceInactive(t *testing.T) {
|
||||
r := &report.NodeReport{
|
||||
Vault: &report.VaultReport{ServiceActive: false},
|
||||
}
|
||||
alerts := checkNodeVault(r, "10.0.0.1")
|
||||
if len(alerts) != 1 {
|
||||
t.Fatalf("expected 1 alert, got %d", len(alerts))
|
||||
}
|
||||
if alerts[0].Severity != AlertCritical {
|
||||
t.Errorf("expected critical, got %s", alerts[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNodeVault_unresponsive(t *testing.T) {
|
||||
r := &report.NodeReport{
|
||||
Vault: &report.VaultReport{ServiceActive: true, Responsive: false},
|
||||
}
|
||||
alerts := checkNodeVault(r, "10.0.0.1")
|
||||
if len(alerts) != 1 {
|
||||
t.Fatalf("expected 1 alert, got %d", len(alerts))
|
||||
}
|
||||
if alerts[0].Severity != AlertWarning {
|
||||
t.Errorf("expected warning, got %s", alerts[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNodeVault_unavailable(t *testing.T) {
|
||||
r := &report.NodeReport{
|
||||
Vault: &report.VaultReport{
|
||||
ServiceActive: true,
|
||||
Responsive: true,
|
||||
Status: "unavailable",
|
||||
Guardians: 5,
|
||||
Healthy: 1,
|
||||
Threshold: 3,
|
||||
WriteQuorum: 4,
|
||||
},
|
||||
}
|
||||
alerts := checkNodeVault(r, "10.0.0.1")
|
||||
if len(alerts) != 1 {
|
||||
t.Fatalf("expected 1 alert, got %d", len(alerts))
|
||||
}
|
||||
if alerts[0].Severity != AlertCritical {
|
||||
t.Errorf("expected critical, got %s", alerts[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNodeVault_degraded(t *testing.T) {
|
||||
r := &report.NodeReport{
|
||||
Vault: &report.VaultReport{
|
||||
ServiceActive: true,
|
||||
Responsive: true,
|
||||
Status: "degraded",
|
||||
Guardians: 5,
|
||||
Healthy: 3,
|
||||
Threshold: 3,
|
||||
WriteQuorum: 4,
|
||||
},
|
||||
}
|
||||
alerts := checkNodeVault(r, "10.0.0.1")
|
||||
if len(alerts) != 1 {
|
||||
t.Fatalf("expected 1 alert, got %d", len(alerts))
|
||||
}
|
||||
if alerts[0].Severity != AlertWarning {
|
||||
t.Errorf("expected warning, got %s", alerts[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNodeVault_excessiveRestarts(t *testing.T) {
|
||||
r := &report.NodeReport{
|
||||
Vault: &report.VaultReport{
|
||||
ServiceActive: true,
|
||||
Responsive: true,
|
||||
Status: "healthy",
|
||||
RestartCount: 5,
|
||||
},
|
||||
}
|
||||
alerts := checkNodeVault(r, "10.0.0.1")
|
||||
if len(alerts) != 1 {
|
||||
t.Fatalf("expected 1 alert, got %d", len(alerts))
|
||||
}
|
||||
if alerts[0].Severity != AlertWarning {
|
||||
t.Errorf("expected warning, got %s", alerts[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNodeVault_healthy(t *testing.T) {
|
||||
r := &report.NodeReport{
|
||||
Vault: &report.VaultReport{
|
||||
ServiceActive: true,
|
||||
Responsive: true,
|
||||
Status: "healthy",
|
||||
Guardians: 5,
|
||||
Healthy: 5,
|
||||
Threshold: 3,
|
||||
WriteQuorum: 4,
|
||||
RestartCount: 0,
|
||||
},
|
||||
}
|
||||
alerts := checkNodeVault(r, "10.0.0.1")
|
||||
if len(alerts) != 0 {
|
||||
t.Errorf("expected 0 alerts for healthy vault, got %d", len(alerts))
|
||||
}
|
||||
}
|
||||
161
core/pkg/cli/noderesolver/resolver.go
Normal file
161
core/pkg/cli/noderesolver/resolver.go
Normal file
@ -0,0 +1,161 @@
|
||||
// Package noderesolver provides unified node discovery for the orama CLI.
|
||||
//
|
||||
// It resolves operator-owned nodes by querying the network's gateway API
|
||||
// (primary) or falling back to the legacy nodes.conf file.
|
||||
package noderesolver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/auth"
|
||||
"github.com/DeBrosOfficial/network/pkg/cli"
|
||||
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
||||
)
|
||||
|
||||
// httpClient is the shared HTTP client for API calls.
|
||||
var httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// ResolveNodes returns the operator's nodes for a given environment.
|
||||
// It first tries the network API (GET /v1/operator/nodes), then falls
|
||||
// back to nodes.conf if the API is unreachable or returns no results.
|
||||
func ResolveNodes(env string) ([]inspector.Node, error) {
|
||||
nodes, err := resolveFromNetwork(env)
|
||||
if err == nil && len(nodes) > 0 {
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// Fallback to nodes.conf
|
||||
confNodes, confErr := remotessh.LoadEnvNodes(env)
|
||||
if confErr != nil {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("network API: %w; nodes.conf: %v", err, confErr)
|
||||
}
|
||||
return nil, confErr
|
||||
}
|
||||
return confNodes, nil
|
||||
}
|
||||
|
||||
// ResolveNodesNetworkOnly queries only the network API without nodes.conf fallback.
|
||||
func ResolveNodesNetworkOnly(env string) ([]inspector.Node, error) {
|
||||
return resolveFromNetwork(env)
|
||||
}
|
||||
|
||||
// resolveFromNetwork queries the gateway API for operator-owned nodes.
|
||||
func resolveFromNetwork(env string) ([]inspector.Node, error) {
|
||||
// 1. Get gateway URL for the environment
|
||||
gatewayURL, err := gatewayURLForEnv(env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve gateway URL: %w", err)
|
||||
}
|
||||
|
||||
// 2. Load stored credentials for this gateway
|
||||
apiKey, err := loadAPIKey(gatewayURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no credentials for %s: %w (run 'orama auth login' first)", gatewayURL, err)
|
||||
}
|
||||
|
||||
return resolveFromNetworkWithURL(gatewayURL, apiKey, env)
|
||||
}
|
||||
|
||||
// resolveFromNetworkWithURL queries a specific gateway URL with an API key.
|
||||
// Exported for testing.
|
||||
func resolveFromNetworkWithURL(gatewayURL, apiKey, env string) ([]inspector.Node, error) {
|
||||
endpoint := fmt.Sprintf("%s/v1/operator/nodes?env=%s", gatewayURL, url.QueryEscape(env))
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reach gateway: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("gateway returned HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Nodes []struct {
|
||||
ID string `json:"id"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
InternalIP string `json:"internal_ip"`
|
||||
Environment string `json:"environment"`
|
||||
Role string `json:"role"`
|
||||
SSHUser string `json:"ssh_user"`
|
||||
Status string `json:"status"`
|
||||
} `json:"nodes"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
nodes := make([]inspector.Node, 0, len(result.Nodes))
|
||||
for _, n := range result.Nodes {
|
||||
user := n.SSHUser
|
||||
if user == "" {
|
||||
user = "root"
|
||||
}
|
||||
// Sandbox nodes share a single SSH key; production nodes use per-host keys.
|
||||
vaultTarget := fmt.Sprintf("%s/%s", n.IPAddress, user)
|
||||
if n.Environment == "sandbox" {
|
||||
vaultTarget = "sandbox/root"
|
||||
}
|
||||
nodes = append(nodes, inspector.Node{
|
||||
Environment: n.Environment,
|
||||
User: user,
|
||||
Host: n.IPAddress,
|
||||
Role: n.Role,
|
||||
VaultTarget: vaultTarget,
|
||||
})
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// gatewayURLForEnv returns the gateway URL for a given environment name.
|
||||
// If env is empty, uses the active environment.
|
||||
func gatewayURLForEnv(env string) (string, error) {
|
||||
if env == "" {
|
||||
e, err := cli.GetActiveEnvironment()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return e.GatewayURL, nil
|
||||
}
|
||||
|
||||
e, err := cli.GetEnvironmentByName(env)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return e.GatewayURL, nil
|
||||
}
|
||||
|
||||
// loadAPIKey loads the stored API key for a gateway URL.
|
||||
func loadAPIKey(gatewayURL string) (string, error) {
|
||||
store, err := auth.LoadEnhancedCredentials()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load credentials: %w", err)
|
||||
}
|
||||
|
||||
creds := store.GetDefaultCredential(gatewayURL)
|
||||
if creds == nil || creds.APIKey == "" {
|
||||
return "", fmt.Errorf("no credentials found for %s", gatewayURL)
|
||||
}
|
||||
|
||||
return creds.APIKey, nil
|
||||
}
|
||||
152
core/pkg/cli/noderesolver/resolver_test.go
Normal file
152
core/pkg/cli/noderesolver/resolver_test.go
Normal file
@ -0,0 +1,152 @@
|
||||
package noderesolver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGatewayURLForEnv_knownEnv(t *testing.T) {
|
||||
url, err := gatewayURLForEnv("devnet")
|
||||
if err != nil {
|
||||
t.Fatalf("gatewayURLForEnv(devnet): %v", err)
|
||||
}
|
||||
if url == "" {
|
||||
t.Error("expected non-empty gateway URL for devnet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayURLForEnv_unknownEnv(t *testing.T) {
|
||||
_, err := gatewayURLForEnv("nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown environment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFromMockServer_happyPath(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/operator/nodes" {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("X-API-Key") != "test-key" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
env := r.URL.Query().Get("env")
|
||||
resp := map[string]interface{}{
|
||||
"nodes": []map[string]string{
|
||||
{"id": "node-1", "ip_address": "1.2.3.4", "environment": env, "role": "nameserver", "ssh_user": "root", "status": "active"},
|
||||
{"id": "node-2", "ip_address": "5.6.7.8", "environment": env, "role": "node", "ssh_user": "ubuntu", "status": "active"},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
nodes, err := resolveFromNetworkWithURL(server.URL, "test-key", "devnet")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveFromNetworkWithURL: %v", err)
|
||||
}
|
||||
|
||||
if len(nodes) != 2 {
|
||||
t.Fatalf("expected 2 nodes, got %d", len(nodes))
|
||||
}
|
||||
|
||||
if nodes[0].Host != "1.2.3.4" {
|
||||
t.Errorf("node 0 host = %q, want %q", nodes[0].Host, "1.2.3.4")
|
||||
}
|
||||
if nodes[0].Role != "nameserver" {
|
||||
t.Errorf("node 0 role = %q, want %q", nodes[0].Role, "nameserver")
|
||||
}
|
||||
if nodes[0].VaultTarget != "1.2.3.4/root" {
|
||||
t.Errorf("node 0 vault target = %q, want %q", nodes[0].VaultTarget, "1.2.3.4/root")
|
||||
}
|
||||
if nodes[0].Environment != "devnet" {
|
||||
t.Errorf("node 0 environment = %q, want %q", nodes[0].Environment, "devnet")
|
||||
}
|
||||
if nodes[1].User != "ubuntu" {
|
||||
t.Errorf("node 1 user = %q, want %q", nodes[1].User, "ubuntu")
|
||||
}
|
||||
if nodes[1].VaultTarget != "5.6.7.8/ubuntu" {
|
||||
t.Errorf("node 1 vault target = %q, want %q", nodes[1].VaultTarget, "5.6.7.8/ubuntu")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFromMockServer_emptySSHUser(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
resp := map[string]interface{}{
|
||||
"nodes": []map[string]string{
|
||||
{"id": "node-1", "ip_address": "1.2.3.4", "environment": "devnet", "role": "node", "ssh_user": "", "status": "active"},
|
||||
},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
nodes, err := resolveFromNetworkWithURL(server.URL, "key", "devnet")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("expected 1 node, got %d", len(nodes))
|
||||
}
|
||||
if nodes[0].User != "root" {
|
||||
t.Errorf("user = %q, want %q (default)", nodes[0].User, "root")
|
||||
}
|
||||
if nodes[0].VaultTarget != "1.2.3.4/root" {
|
||||
t.Errorf("vault target = %q, want %q", nodes[0].VaultTarget, "1.2.3.4/root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFromMockServer_unauthorized(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := resolveFromNetworkWithURL(server.URL, "bad-key", "devnet")
|
||||
if err == nil {
|
||||
t.Error("expected error for unauthorized request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFromMockServer_emptyNodes(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"nodes": []interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
nodes, err := resolveFromNetworkWithURL(server.URL, "key", "devnet")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(nodes) != 0 {
|
||||
t.Errorf("expected 0 nodes, got %d", len(nodes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFromMockServer_malformedJSON(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<html>not json</html>`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := resolveFromNetworkWithURL(server.URL, "key", "devnet")
|
||||
if err == nil {
|
||||
t.Error("expected error for malformed JSON response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveFromMockServer_serverDown(t *testing.T) {
|
||||
_, err := resolveFromNetworkWithURL("http://127.0.0.1:1", "key", "devnet")
|
||||
if err == nil {
|
||||
t.Error("expected error for unreachable server")
|
||||
}
|
||||
}
|
||||
@ -133,7 +133,7 @@ func cleanNode(node inspector.Node, nuclear bool) error {
|
||||
%s
|
||||
|
||||
# Stop services
|
||||
for svc in caddy coredns orama-node orama-gateway orama-ipfs-cluster orama-ipfs orama-olric orama-anyone-relay orama-anyone-client; do
|
||||
for svc in caddy coredns orama-node orama-gateway orama-ipfs-cluster orama-ipfs orama-olric orama-vault orama-anyone-relay orama-anyone-client; do
|
||||
systemctl stop "$svc" 2>/dev/null
|
||||
systemctl disable "$svc" 2>/dev/null
|
||||
done
|
||||
@ -171,7 +171,7 @@ rm -f /tmp/orama-*.sh /tmp/network-source.tar.gz /tmp/orama-*.tar.gz
|
||||
# Nuclear: remove binaries
|
||||
if [ -n "$NUCLEAR" ]; then
|
||||
rm -f /usr/local/bin/orama /usr/local/bin/orama-node /usr/local/bin/gateway
|
||||
rm -f /usr/local/bin/identity /usr/local/bin/sfu /usr/local/bin/turn
|
||||
rm -f /usr/local/bin/identity /usr/local/bin/sfu /usr/local/bin/turn /usr/local/bin/orama-sni-router
|
||||
rm -f /usr/local/bin/olric-server /usr/local/bin/ipfs /usr/local/bin/ipfs-cluster-service
|
||||
rm -f /usr/local/bin/rqlited /usr/local/bin/coredns
|
||||
rm -f /usr/bin/caddy
|
||||
|
||||
@ -43,6 +43,11 @@ type Flags struct {
|
||||
AnyoneFamily string // Comma-separated fingerprints of other relays you operate
|
||||
AnyoneBandwidth int // Percentage of VPS bandwidth for relay (default: 30, 0=unlimited)
|
||||
AnyoneAccounting int // Monthly data cap for relay in GB (0=unlimited)
|
||||
|
||||
// Operator metadata (set by orama node setup, written to node.yaml for registration)
|
||||
SSHUser string // SSH user for remote management
|
||||
Environment string // Environment name (devnet, testnet, etc.)
|
||||
OperatorWallet string // Operator wallet address
|
||||
}
|
||||
|
||||
// ParseFlags parses install command flags
|
||||
@ -90,6 +95,11 @@ func ParseFlags(args []string) (*Flags, error) {
|
||||
fs.IntVar(&flags.AnyoneBandwidth, "anyone-bandwidth", 30, "Limit relay to N% of VPS bandwidth (0=unlimited, runs speedtest)")
|
||||
fs.IntVar(&flags.AnyoneAccounting, "anyone-accounting", 0, "Monthly data cap for relay in GB (0=unlimited)")
|
||||
|
||||
// Operator metadata (set by orama node setup)
|
||||
fs.StringVar(&flags.SSHUser, "ssh-user", "", "SSH user for remote management")
|
||||
fs.StringVar(&flags.Environment, "environment", "", "Environment name (devnet, testnet, etc.)")
|
||||
fs.StringVar(&flags.OperatorWallet, "operator-wallet", "", "Operator wallet address")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if err == flag.ErrHelp {
|
||||
return nil, err
|
||||
|
||||
@ -68,6 +68,11 @@ func NewOrchestrator(flags *Flags) (*Orchestrator, error) {
|
||||
setup.SetAnyoneClient(true)
|
||||
}
|
||||
|
||||
// Set operator metadata (from orama node setup)
|
||||
setup.SSHUser = flags.SSHUser
|
||||
setup.Environment = flags.Environment
|
||||
setup.OperatorWallet = flags.OperatorWallet
|
||||
|
||||
validator := NewValidator(flags, oramaDir)
|
||||
|
||||
return &Orchestrator{
|
||||
|
||||
@ -53,6 +53,7 @@ func HandleRestartWithFlags(force bool) {
|
||||
{"orama-node"},
|
||||
{"orama-olric"},
|
||||
{"orama-ipfs-cluster", "orama-ipfs"},
|
||||
{"orama-vault"},
|
||||
{"orama-anyone-relay", "orama-anyone-client"},
|
||||
{"coredns", "caddy"},
|
||||
}
|
||||
|
||||
@ -55,8 +55,9 @@ func HandleStopWithFlags(force bool) {
|
||||
{"orama-node"}, // 1. Stop node (includes gateway + RQLite with leadership transfer)
|
||||
{"orama-olric"}, // 2. Stop cache
|
||||
{"orama-ipfs-cluster", "orama-ipfs"}, // 3. Stop storage
|
||||
{"orama-anyone-relay", "orama-anyone-client"}, // 4. Stop privacy relay
|
||||
{"coredns", "caddy"}, // 5. Stop DNS/TLS last
|
||||
{"orama-vault"}, // 4. Stop vault
|
||||
{"orama-anyone-relay", "orama-anyone-client"}, // 5. Stop privacy relay
|
||||
{"coredns", "caddy"}, // 6. Stop DNS/TLS last
|
||||
}
|
||||
|
||||
// Mask all services to immediately prevent Restart=always from reviving them.
|
||||
|
||||
@ -89,6 +89,7 @@ func collectProcesses() *ProcessReport {
|
||||
var managedServiceUnits = []string{
|
||||
"orama-node", "orama-olric",
|
||||
"orama-ipfs", "orama-ipfs-cluster",
|
||||
"orama-vault",
|
||||
"orama-anyone-relay", "orama-anyone-client",
|
||||
"coredns", "caddy", "rqlited",
|
||||
}
|
||||
|
||||
@ -71,6 +71,10 @@ func Handle(jsonFlag bool, version string) error {
|
||||
rpt.IPFS = collectIPFS()
|
||||
})
|
||||
|
||||
safeGo(&wg, "vault", func() {
|
||||
rpt.Vault = collectVault()
|
||||
})
|
||||
|
||||
safeGo(&wg, "gateway", func() {
|
||||
rpt.Gateway = collectGateway()
|
||||
})
|
||||
|
||||
@ -13,6 +13,7 @@ var coreServices = []string{
|
||||
"orama-olric",
|
||||
"orama-ipfs",
|
||||
"orama-ipfs-cluster",
|
||||
"orama-vault",
|
||||
"orama-anyone-relay",
|
||||
"orama-anyone-client",
|
||||
"coredns",
|
||||
|
||||
@ -17,6 +17,7 @@ type NodeReport struct {
|
||||
RQLite *RQLiteReport `json:"rqlite,omitempty"`
|
||||
Olric *OlricReport `json:"olric,omitempty"`
|
||||
IPFS *IPFSReport `json:"ipfs,omitempty"`
|
||||
Vault *VaultReport `json:"vault,omitempty"`
|
||||
Gateway *GatewayReport `json:"gateway,omitempty"`
|
||||
WireGuard *WireGuardReport `json:"wireguard,omitempty"`
|
||||
DNS *DNSReport `json:"dns,omitempty"`
|
||||
@ -150,6 +151,21 @@ type IPFSReport struct {
|
||||
BootstrapEmpty bool `json:"bootstrap_empty"`
|
||||
}
|
||||
|
||||
// --- Vault ---
|
||||
|
||||
type VaultReport struct {
|
||||
ServiceActive bool `json:"service_active"`
|
||||
Responsive bool `json:"responsive"`
|
||||
Status string `json:"status,omitempty"` // "healthy", "degraded", "unavailable"
|
||||
Guardians int `json:"guardians,omitempty"`
|
||||
Healthy int `json:"healthy,omitempty"`
|
||||
Threshold int `json:"threshold,omitempty"`
|
||||
WriteQuorum int `json:"write_quorum,omitempty"`
|
||||
ProcessMemMB int `json:"process_mem_mb"`
|
||||
RestartCount int `json:"restart_count"`
|
||||
LogErrors int `json:"log_errors_1h"`
|
||||
}
|
||||
|
||||
// --- Gateway ---
|
||||
|
||||
type GatewayReport struct {
|
||||
|
||||
70
core/pkg/cli/production/report/vault.go
Normal file
70
core/pkg/cli/production/report/vault.go
Normal file
@ -0,0 +1,70 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func collectVault() *VaultReport {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r := &VaultReport{}
|
||||
|
||||
// 1. Service active
|
||||
if out, err := runCmd(ctx, "systemctl", "is-active", "orama-vault"); err == nil {
|
||||
r.ServiceActive = strings.TrimSpace(out) == "active"
|
||||
}
|
||||
|
||||
// 2. Restart count
|
||||
if out, err := runCmd(ctx, "systemctl", "show", "orama-vault", "--property=NRestarts"); err == nil {
|
||||
if parts := strings.SplitN(out, "=", 2); len(parts) == 2 {
|
||||
r.RestartCount, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Process memory
|
||||
if out, err := runCmd(ctx, "systemctl", "show", "orama-vault", "--property=MemoryCurrent"); err == nil {
|
||||
if parts := strings.SplitN(out, "=", 2); len(parts) == 2 {
|
||||
r.ProcessMemMB = parseMemoryMB(parts[1])
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Log errors in last hour
|
||||
if out, err := runCmd(ctx, "bash", "-c",
|
||||
`journalctl -u orama-vault --no-pager -n 200 --since "1 hour ago" 2>/dev/null | grep -ciE "(error|ERR)" || echo 0`); err == nil {
|
||||
r.LogErrors, _ = strconv.Atoi(strings.TrimSpace(out))
|
||||
}
|
||||
|
||||
// 5. Query vault status via gateway (provides guardian health)
|
||||
if body, err := httpGet(ctx, "http://localhost:6001/v1/vault/status"); err == nil {
|
||||
var status struct {
|
||||
Guardians int `json:"guardians"`
|
||||
Healthy int `json:"healthy"`
|
||||
Threshold int `json:"threshold"`
|
||||
WriteQuorum int `json:"write_quorum"`
|
||||
}
|
||||
if json.Unmarshal(body, &status) == nil {
|
||||
r.Responsive = true
|
||||
r.Guardians = status.Guardians
|
||||
r.Healthy = status.Healthy
|
||||
r.Threshold = status.Threshold
|
||||
r.WriteQuorum = status.WriteQuorum
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Query vault health status
|
||||
if body, err := httpGet(ctx, "http://localhost:6001/v1/vault/health"); err == nil {
|
||||
var health struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if json.Unmarshal(body, &health) == nil {
|
||||
r.Status = health.Status
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
369
core/pkg/cli/production/setup/command.go
Normal file
369
core/pkg/cli/production/setup/command.go
Normal file
@ -0,0 +1,369 @@
|
||||
// Package setup implements the "orama node setup" command — a single command
|
||||
// to bootstrap a fresh VPS into a running Orama node.
|
||||
//
|
||||
// Flow:
|
||||
// 1. Create SSH key in rootwallet vault for this node
|
||||
// 2. Install the public key on the VPS (one-time password-based SSH)
|
||||
// 3. Upload the binary archive
|
||||
// 4. For genesis: run install without --join
|
||||
// 5. For joining: request invite token via operator API, run install with --join
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/auth"
|
||||
"github.com/DeBrosOfficial/network/pkg/cli"
|
||||
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
||||
"github.com/DeBrosOfficial/network/pkg/rwagent"
|
||||
)
|
||||
|
||||
// Options holds the flags for the setup command.
|
||||
type Options struct {
|
||||
IP string
|
||||
Env string
|
||||
Role string // "node" or "nameserver"
|
||||
User string // SSH user (default: "root")
|
||||
Password string // One-time password for initial SSH access
|
||||
BaseDomain string
|
||||
Gateway string // Gateway URL to use for invite tokens (overrides env config)
|
||||
Genesis bool // If true, create a new cluster instead of joining
|
||||
AnyoneRelay bool
|
||||
}
|
||||
|
||||
// Run executes the node setup.
|
||||
func Run(opts Options) error {
|
||||
if opts.IP == "" {
|
||||
return fmt.Errorf("--ip is required")
|
||||
}
|
||||
if opts.User == "" {
|
||||
opts.User = "root"
|
||||
}
|
||||
if opts.Role == "" {
|
||||
opts.Role = "node"
|
||||
}
|
||||
|
||||
// 1. Ensure rootwallet agent is running
|
||||
fmt.Println("Checking rootwallet agent...")
|
||||
agentClient := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
status, err := agentClient.Status(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rootwallet agent not reachable: %w (is the desktop app running?)", err)
|
||||
}
|
||||
if status.Locked {
|
||||
return fmt.Errorf("rootwallet agent is locked — unlock it in the desktop app first")
|
||||
}
|
||||
|
||||
// 2. Get operator wallet address
|
||||
addrData, err := agentClient.GetAddress(ctx, "evm")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get wallet address: %w", err)
|
||||
}
|
||||
fmt.Printf(" Wallet: %s\n", addrData.Address)
|
||||
|
||||
// 3. Create SSH key in rootwallet vault for this node
|
||||
vaultTarget := fmt.Sprintf("%s/%s", opts.IP, opts.User)
|
||||
fmt.Printf(" Setting up SSH key for %s...\n", vaultTarget)
|
||||
|
||||
if err := remotessh.EnsureVaultEntry(vaultTarget); err != nil {
|
||||
return fmt.Errorf("failed to create SSH key in vault: %w", err)
|
||||
}
|
||||
|
||||
pubKey, err := remotessh.ResolveVaultPublicKey(vaultTarget)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
|
||||
// 4. Install the public key on the VPS via password SSH
|
||||
if opts.Password != "" {
|
||||
fmt.Printf(" Installing SSH key on %s...\n", opts.IP)
|
||||
if err := installPublicKey(opts.IP, opts.User, opts.Password, pubKey); err != nil {
|
||||
return fmt.Errorf("failed to install SSH key: %w", err)
|
||||
}
|
||||
fmt.Println(" SSH key installed")
|
||||
} else {
|
||||
fmt.Println(" No --password provided, assuming SSH key is already installed")
|
||||
}
|
||||
|
||||
// 5. Test SSH with rootwallet key
|
||||
fmt.Println(" Testing SSH connection...")
|
||||
node := inspector.Node{
|
||||
Host: opts.IP,
|
||||
User: opts.User,
|
||||
VaultTarget: vaultTarget,
|
||||
Environment: opts.Env,
|
||||
Role: opts.Role,
|
||||
}
|
||||
nodes := []inspector.Node{node}
|
||||
cleanup, err := remotessh.PrepareNodeKeys(nodes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prepare SSH key: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
node = nodes[0] // SSHKey is now set
|
||||
|
||||
testResult := inspector.RunSSH(context.Background(), node, "echo ok")
|
||||
if !testResult.OK() {
|
||||
return fmt.Errorf("SSH test failed: %s", testResult.Stderr)
|
||||
}
|
||||
fmt.Println(" SSH connection OK")
|
||||
|
||||
// 6. Check if binary archive needs uploading
|
||||
if needsArchiveUpload(node) {
|
||||
archivePath := findNewestArchive()
|
||||
if archivePath == "" {
|
||||
return fmt.Errorf("no binary archive found in /tmp/ (run `orama build` first)")
|
||||
}
|
||||
fmt.Printf(" Uploading archive (%s)...\n", filepath.Base(archivePath))
|
||||
if err := remotessh.UploadFile(node, archivePath, "/tmp/archive.tar.gz"); err != nil {
|
||||
return fmt.Errorf("failed to upload archive: %w", err)
|
||||
}
|
||||
extractCmd := "sudo bash -c 'mkdir -p /opt/orama && tar xzf /tmp/archive.tar.gz -C /opt/orama && rm -f /tmp/archive.tar.gz'"
|
||||
if err := remotessh.RunSSHStreaming(node, extractCmd); err != nil {
|
||||
return fmt.Errorf("failed to extract archive: %w", err)
|
||||
}
|
||||
fmt.Println(" Archive extracted")
|
||||
} else {
|
||||
fmt.Println(" Binary already present on node")
|
||||
}
|
||||
|
||||
// 7. Build the install command
|
||||
installCmd, err := buildInstallCommand(opts, node, agentClient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build install command: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("\n Running: %s\n\n", installCmd)
|
||||
|
||||
// 8. Run the install
|
||||
if err := remotessh.RunSSHStreaming(node, installCmd); err != nil {
|
||||
return fmt.Errorf("install failed: %w", err)
|
||||
}
|
||||
|
||||
// 9. After genesis install, update the environment gateway URL to this node's IP.
|
||||
// This allows subsequent `node setup` calls to find the gateway automatically.
|
||||
if opts.Genesis && opts.Env != "" {
|
||||
gatewayURL := fmt.Sprintf("http://%s", opts.IP)
|
||||
desc := fmt.Sprintf("%s (genesis: %s)", opts.Env, opts.IP)
|
||||
if err := cli.AddEnvironment(opts.Env, gatewayURL, desc); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " Warning: failed to update environment: %v\n", err)
|
||||
} else {
|
||||
if err := cli.SwitchEnvironment(opts.Env); err != nil {
|
||||
fmt.Fprintf(os.Stderr, " Warning: failed to switch environment: %v\n", err)
|
||||
}
|
||||
fmt.Printf(" Environment %q updated: gateway → %s\n", opts.Env, gatewayURL)
|
||||
fmt.Printf("\n To join more nodes, first authenticate:\n")
|
||||
fmt.Printf(" orama auth login\n")
|
||||
fmt.Printf(" Then:\n")
|
||||
fmt.Printf(" orama node setup --ip <IP> --password '<PASS>' --env %s --base-domain %s\n", opts.Env, opts.BaseDomain)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n Node %s setup complete!\n", opts.IP)
|
||||
return nil
|
||||
}
|
||||
|
||||
// installPublicKey installs an SSH public key on a VPS using password authentication.
|
||||
func installPublicKey(ip, user, password, pubKey string) error {
|
||||
sshpassBin, err := findBinary("sshpass")
|
||||
if err != nil {
|
||||
return fmt.Errorf("sshpass is required for password-based SSH key installation: %w", err)
|
||||
}
|
||||
|
||||
// Ensure .ssh directory exists and install the key
|
||||
cmd := fmt.Sprintf(
|
||||
`mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '%s' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && echo 'key installed'`,
|
||||
strings.TrimSpace(pubKey),
|
||||
)
|
||||
|
||||
args := []string{
|
||||
"-p", password,
|
||||
"ssh",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"-o", "PreferredAuthentications=password",
|
||||
"-o", "PubkeyAuthentication=no",
|
||||
fmt.Sprintf("%s@%s", user, ip),
|
||||
cmd,
|
||||
}
|
||||
|
||||
out, err := runCommand(sshpassBin, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sshpass failed: %w (%s)", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "key installed") {
|
||||
return fmt.Errorf("unexpected output: %s", out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildInstallCommand constructs the `sudo orama node install` command.
|
||||
func buildInstallCommand(opts Options, node inspector.Node, agentClient *rwagent.Client) (string, error) {
|
||||
parts := []string{"sudo /opt/orama/bin/orama node install"}
|
||||
parts = append(parts, "--vps-ip", opts.IP)
|
||||
|
||||
if opts.BaseDomain != "" {
|
||||
parts = append(parts, "--base-domain", opts.BaseDomain)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(opts.Role, "nameserver") {
|
||||
parts = append(parts, "--nameserver")
|
||||
if opts.BaseDomain != "" {
|
||||
parts = append(parts, "--domain", opts.BaseDomain)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.AnyoneRelay {
|
||||
parts = append(parts, "--anyone-relay")
|
||||
} else {
|
||||
parts = append(parts, "--anyone-client")
|
||||
}
|
||||
|
||||
// Pass operator metadata so the node registers with correct values
|
||||
if opts.User != "" {
|
||||
parts = append(parts, "--ssh-user", opts.User)
|
||||
}
|
||||
if opts.Env != "" {
|
||||
parts = append(parts, "--environment", opts.Env)
|
||||
}
|
||||
|
||||
// Get wallet address for operator tagging
|
||||
ctx := context.Background()
|
||||
if addrData, err := agentClient.GetAddress(ctx, "evm"); err == nil && addrData.Address != "" {
|
||||
parts = append(parts, "--operator-wallet", addrData.Address)
|
||||
}
|
||||
|
||||
if !opts.Genesis {
|
||||
// Determine gateway URL for invite token request
|
||||
gatewayURL := opts.Gateway
|
||||
if gatewayURL == "" {
|
||||
env := opts.Env
|
||||
if env == "" {
|
||||
active, err := cli.GetActiveEnvironment()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get active environment: %w", err)
|
||||
}
|
||||
env = active.Name
|
||||
}
|
||||
envConfig, err := cli.GetEnvironmentByName(env)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("environment %q not found (use --gateway to specify directly): %w", env, err)
|
||||
}
|
||||
gatewayURL = envConfig.GatewayURL
|
||||
}
|
||||
|
||||
// Request invite token via operator API
|
||||
token, err := requestInviteToken(gatewayURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get invite token: %w", err)
|
||||
}
|
||||
|
||||
parts = append(parts, "--join", gatewayURL, "--token", token)
|
||||
}
|
||||
|
||||
return strings.Join(parts, " "), nil
|
||||
}
|
||||
|
||||
// requestInviteToken calls POST /v1/operator/invite to get an invite token.
|
||||
func requestInviteToken(gatewayURL string) (string, error) {
|
||||
store, err := auth.LoadEnhancedCredentials()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load credentials: %w", err)
|
||||
}
|
||||
creds := store.GetDefaultCredential(gatewayURL)
|
||||
if creds == nil || creds.APIKey == "" {
|
||||
return "", fmt.Errorf("no credentials for %s — run 'orama auth login' first", gatewayURL)
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]int{"expiry_minutes": 60})
|
||||
req, err := http.NewRequest(http.MethodPost, gatewayURL+"/v1/operator/invite", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-API-Key", creds.APIKey)
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if result.Token == "" {
|
||||
return "", fmt.Errorf("empty token in response")
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
// needsArchiveUpload checks if the node already has the orama binary.
|
||||
func needsArchiveUpload(node inspector.Node) bool {
|
||||
result := inspector.RunSSH(context.Background(), node, "/opt/orama/bin/orama version 2>/dev/null")
|
||||
return !result.OK()
|
||||
}
|
||||
|
||||
// findNewestArchive finds the newest orama binary archive in /tmp/.
|
||||
func findNewestArchive() string {
|
||||
matches, _ := filepath.Glob("/tmp/orama-*-linux-*.tar.gz")
|
||||
if len(matches) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Slice(matches, func(i, j int) bool {
|
||||
fi, _ := os.Stat(matches[i])
|
||||
fj, _ := os.Stat(matches[j])
|
||||
if fi == nil || fj == nil {
|
||||
return false
|
||||
}
|
||||
return fi.ModTime().After(fj.ModTime())
|
||||
})
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
func findBinary(name string) (string, error) {
|
||||
paths := []string{
|
||||
"/opt/homebrew/bin/" + name,
|
||||
"/usr/local/bin/" + name,
|
||||
"/usr/bin/" + name,
|
||||
}
|
||||
for _, p := range paths {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s not found", name)
|
||||
}
|
||||
|
||||
func runCommand(bin string, args ...string) (string, error) {
|
||||
cmd := &exec.Cmd{
|
||||
Path: bin,
|
||||
Args: append([]string{bin}, args...),
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
@ -17,6 +17,7 @@ func Handle() {
|
||||
"orama-ipfs-cluster",
|
||||
// Note: RQLite is managed by node process, not as separate service
|
||||
"orama-olric",
|
||||
"orama-vault",
|
||||
"orama-node",
|
||||
// Note: gateway is embedded in orama-node, no separate service
|
||||
}
|
||||
@ -26,6 +27,7 @@ func Handle() {
|
||||
"orama-ipfs": "IPFS Daemon",
|
||||
"orama-ipfs-cluster": "IPFS Cluster",
|
||||
"orama-olric": "Olric Cache Server",
|
||||
"orama-vault": "Vault Guardian",
|
||||
"orama-node": "Orama Node (includes RQLite + Gateway)",
|
||||
}
|
||||
|
||||
|
||||
@ -376,6 +376,7 @@ func (o *Orchestrator) stopServices() error {
|
||||
"orama-ipfs-cluster.service", // Depends on IPFS
|
||||
"orama-ipfs.service", // Base IPFS
|
||||
"orama-olric.service", // Independent
|
||||
"orama-vault.service", // Vault guardian
|
||||
"orama-anyone-client.service", // Client mode
|
||||
"orama-anyone-relay.service", // Relay mode
|
||||
}
|
||||
@ -683,6 +684,7 @@ func (o *Orchestrator) restartServices() error {
|
||||
"orama-olric", // Distributed cache
|
||||
"orama-ipfs", // IPFS daemon
|
||||
"orama-ipfs-cluster", // IPFS cluster
|
||||
"orama-vault", // Vault guardian
|
||||
"orama-gateway", // Gateway (legacy)
|
||||
"coredns", // DNS server
|
||||
"caddy", // Reverse proxy
|
||||
|
||||
@ -42,7 +42,7 @@ func UploadFile(node inspector.Node, localPath, remotePath string, opts ...SSHOp
|
||||
|
||||
dest := fmt.Sprintf("%s@%s:%s", node.User, node.Host, remotePath)
|
||||
|
||||
args := []string{"-o", "ConnectTimeout=10", "-i", node.SSHKey}
|
||||
args := []string{"-o", "ConnectTimeout=10", "-o", "IdentitiesOnly=yes", "-i", node.SSHKey}
|
||||
if cfg.noHostKeyCheck {
|
||||
args = append([]string{"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"}, args...)
|
||||
} else {
|
||||
@ -73,7 +73,7 @@ func RunSSHStreaming(node inspector.Node, command string, opts ...SSHOption) err
|
||||
o(&cfg)
|
||||
}
|
||||
|
||||
args := []string{"-o", "ConnectTimeout=10", "-i", node.SSHKey}
|
||||
args := []string{"-o", "ConnectTimeout=10", "-o", "IdentitiesOnly=yes", "-i", node.SSHKey}
|
||||
if cfg.noHostKeyCheck {
|
||||
args = append([]string{"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"}, args...)
|
||||
} else {
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/cli"
|
||||
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
||||
"github.com/DeBrosOfficial/network/pkg/rwagent"
|
||||
@ -144,6 +145,18 @@ func Create(name string) error {
|
||||
return fmt.Errorf("save final state: %w", err)
|
||||
}
|
||||
|
||||
// Register sandbox as an environment and switch to it
|
||||
gatewayURL := "https://" + cfg.Domain
|
||||
desc := fmt.Sprintf("Sandbox cluster: %s (%s)", state.Name, cfg.Domain)
|
||||
if err := cli.AddEnvironment("sandbox", gatewayURL, desc); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to register sandbox environment: %v\n", err)
|
||||
} else if err := cli.SwitchEnvironment("sandbox"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to switch to sandbox environment: %v\n", err)
|
||||
}
|
||||
|
||||
// Tag all nodes with operator wallet for unified node management
|
||||
registerNodesWithOperator(state, sshKeyPath)
|
||||
|
||||
printCreateSummary(cfg, state)
|
||||
return nil
|
||||
}
|
||||
@ -633,6 +646,36 @@ func printCreateSummary(cfg *Config, state *SandboxState) {
|
||||
fmt.Println("Destroy: orama sandbox destroy")
|
||||
}
|
||||
|
||||
// registerNodesWithOperator tags all sandbox nodes with the operator's wallet
|
||||
// via a direct RQLite UPDATE on the genesis node. This enables `orama nodes`
|
||||
// to discover sandbox nodes alongside production nodes.
|
||||
func registerNodesWithOperator(state *SandboxState, sshKeyPath string) {
|
||||
client := rwagent.New(os.Getenv("RW_AGENT_SOCK"))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
addrData, err := client.GetAddress(ctx, "evm")
|
||||
if err != nil || addrData == nil || addrData.Address == "" {
|
||||
fmt.Fprintf(os.Stderr, "Warning: could not get operator wallet, nodes not tagged: %v\n", err)
|
||||
return
|
||||
}
|
||||
wallet := addrData.Address
|
||||
|
||||
if len(state.Servers) == 0 {
|
||||
return
|
||||
}
|
||||
genesis := state.Servers[0]
|
||||
|
||||
node := inspector.Node{User: "root", Host: genesis.IP, SSHKey: sshKeyPath}
|
||||
// Use RQLite's parameterized query to avoid any injection risk.
|
||||
// The JSON payload has the wallet as a parameter, not interpolated into SQL.
|
||||
payload := fmt.Sprintf(`[["UPDATE dns_nodes SET operator_wallet = ?, environment = 'sandbox' WHERE operator_wallet IS NULL OR operator_wallet = ''", %q]]`, wallet)
|
||||
cmd := fmt.Sprintf(`curl -sf -X POST http://localhost:5001/db/execute -H 'Content-Type: application/json' -d '%s'`, payload)
|
||||
if _, err := runSSHOutput(node, cmd); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to tag nodes with operator wallet: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupFailedCreate deletes any servers that were created during a failed provision.
|
||||
func cleanupFailedCreate(client *HetznerClient, state *SandboxState) {
|
||||
if len(state.Servers) == 0 {
|
||||
|
||||
@ -4,8 +4,11 @@ import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/cli"
|
||||
)
|
||||
|
||||
// Destroy tears down a sandbox cluster.
|
||||
@ -100,10 +103,30 @@ func Destroy(name string, force bool) error {
|
||||
return fmt.Errorf("delete state: %w", err)
|
||||
}
|
||||
|
||||
// Remove sandbox environment entry, fall back to devnet
|
||||
if err := cli.RemoveEnvironment("sandbox"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to remove sandbox environment: %v\n", err)
|
||||
}
|
||||
|
||||
// Clean up SSH known_hosts entries for destroyed server IPs.
|
||||
// This prevents "REMOTE HOST IDENTIFICATION HAS CHANGED" errors
|
||||
// when the same IPs are reused by a new sandbox.
|
||||
cleanupKnownHosts(state)
|
||||
|
||||
fmt.Printf("\nSandbox %q destroyed (%d servers deleted)\n", state.Name, len(state.Servers))
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupKnownHosts removes SSH known_hosts entries for all sandbox server IPs.
|
||||
func cleanupKnownHosts(state *SandboxState) {
|
||||
for _, srv := range state.Servers {
|
||||
cmd := exec.Command("ssh-keygen", "-R", srv.IP)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.Run() // best-effort, ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// resolveSandbox finds a sandbox by name or returns the active one.
|
||||
func resolveSandbox(name string) (*SandboxState, error) {
|
||||
if name != "" {
|
||||
|
||||
@ -162,6 +162,7 @@ func GetProductionServices() []string {
|
||||
"orama-olric",
|
||||
"orama-ipfs-cluster",
|
||||
"orama-ipfs",
|
||||
"orama-vault",
|
||||
"orama-anyone-client",
|
||||
"orama-anyone-relay",
|
||||
}
|
||||
|
||||
@ -47,10 +47,29 @@ type DatabaseClient interface {
|
||||
type PubSubClient interface {
|
||||
Subscribe(ctx context.Context, topic string, handler MessageHandler) error
|
||||
Publish(ctx context.Context, topic string, data []byte) error
|
||||
// PublishBatch publishes multiple messages in parallel, one per topic.
|
||||
// See pubsub.Manager.PublishBatch for semantics (fail-fast vs. best-effort).
|
||||
PublishBatch(ctx context.Context, msgs []TopicMessage, opts PublishBatchOptions) error
|
||||
// PublishSame sends the same payload to every topic in parallel.
|
||||
PublishSame(ctx context.Context, topics []string, data []byte, opts PublishBatchOptions) error
|
||||
Unsubscribe(ctx context.Context, topic string) error
|
||||
ListTopics(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
// TopicMessage is one entry in a batch publish.
|
||||
// Mirrors pubsub.TopicMessage to avoid forcing client callers to import pkg/pubsub.
|
||||
type TopicMessage struct {
|
||||
Topic string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// PublishBatchOptions controls batch publish behavior.
|
||||
// Mirrors pubsub.PublishBatchOptions.
|
||||
type PublishBatchOptions struct {
|
||||
BestEffort bool
|
||||
MaxConcurrency int
|
||||
}
|
||||
|
||||
// NetworkInfo provides network status and peer information
|
||||
type NetworkInfo interface {
|
||||
GetPeers(ctx context.Context) ([]PeerInfo, error)
|
||||
|
||||
@ -4,13 +4,13 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/pubsub"
|
||||
pkgpubsub "github.com/DeBrosOfficial/network/pkg/pubsub"
|
||||
)
|
||||
|
||||
// pubSubBridge bridges between our PubSubClient interface and the pubsub package
|
||||
type pubSubBridge struct {
|
||||
client *Client
|
||||
adapter *pubsub.ClientAdapter
|
||||
adapter *pkgpubsub.ClientAdapter
|
||||
}
|
||||
|
||||
func (p *pubSubBridge) Subscribe(ctx context.Context, topic string, handler MessageHandler) error {
|
||||
@ -31,6 +31,26 @@ func (p *pubSubBridge) Publish(ctx context.Context, topic string, data []byte) e
|
||||
return p.adapter.Publish(ctx, topic, data)
|
||||
}
|
||||
|
||||
func (p *pubSubBridge) PublishBatch(ctx context.Context, msgs []TopicMessage, opts PublishBatchOptions) error {
|
||||
if err := p.client.requireAccess(ctx); err != nil {
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
pkgMsgs := make([]pkgpubsub.TopicMessage, len(msgs))
|
||||
for i, m := range msgs {
|
||||
pkgMsgs[i] = pkgpubsub.TopicMessage{Topic: m.Topic, Data: m.Data}
|
||||
}
|
||||
pkgOpts := pkgpubsub.PublishBatchOptions{BestEffort: opts.BestEffort, MaxConcurrency: opts.MaxConcurrency}
|
||||
return p.adapter.PublishBatch(ctx, pkgMsgs, pkgOpts)
|
||||
}
|
||||
|
||||
func (p *pubSubBridge) PublishSame(ctx context.Context, topics []string, data []byte, opts PublishBatchOptions) error {
|
||||
if err := p.client.requireAccess(ctx); err != nil {
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
}
|
||||
pkgOpts := pkgpubsub.PublishBatchOptions{BestEffort: opts.BestEffort, MaxConcurrency: opts.MaxConcurrency}
|
||||
return p.adapter.PublishSame(ctx, topics, data, pkgOpts)
|
||||
}
|
||||
|
||||
func (p *pubSubBridge) Unsubscribe(ctx context.Context, topic string) error {
|
||||
if err := p.client.requireAccess(ctx); err != nil {
|
||||
return fmt.Errorf("authentication required: %w - run CLI commands to authenticate automatically", err)
|
||||
|
||||
@ -7,4 +7,7 @@ type NodeConfig struct {
|
||||
DataDir string `yaml:"data_dir"` // Data directory
|
||||
MaxConnections int `yaml:"max_connections"` // Maximum peer connections
|
||||
Domain string `yaml:"domain"` // Domain for this node (e.g., node-1.orama.network)
|
||||
SSHUser string `yaml:"ssh_user,omitempty"` // SSH user for remote management
|
||||
Environment string `yaml:"environment,omitempty"` // Environment name (devnet, testnet, etc.)
|
||||
OperatorWallet string `yaml:"operator_wallet,omitempty"` // Operator wallet address
|
||||
}
|
||||
|
||||
@ -181,6 +181,10 @@ func (m *mockHomeNodeDB) Tx(ctx context.Context, fn func(tx rqlite.Tx) error) er
|
||||
return m.mockRQLiteClient.Tx(ctx, fn)
|
||||
}
|
||||
|
||||
func (m *mockHomeNodeDB) Batch(ctx context.Context, ops []rqlite.BatchOp) (*rqlite.BatchResult, error) {
|
||||
return m.mockRQLiteClient.Batch(ctx, ops)
|
||||
}
|
||||
|
||||
func (m *mockHomeNodeDB) addDeployment(nodeID, deploymentID, status string) {
|
||||
m.deployments[nodeID] = append(m.deployments[nodeID], deploymentData{
|
||||
id: deploymentID,
|
||||
|
||||
@ -149,6 +149,15 @@ func (m *mockRQLiteClient) Tx(ctx context.Context, fn func(tx rqlite.Tx) error)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRQLiteClient) Batch(ctx context.Context, ops []rqlite.BatchOp) (*rqlite.BatchResult, error) {
|
||||
return &rqlite.BatchResult{Committed: true, Results: make([]rqlite.OpResult, len(ops))}, nil
|
||||
}
|
||||
|
||||
func (m *mockRQLiteClient) BatchWithSeq(ctx context.Context, namespace string, ops []rqlite.BatchOp) (*rqlite.BatchResult, int64, error) {
|
||||
res, err := m.Batch(ctx, ops)
|
||||
return res, 1, err
|
||||
}
|
||||
|
||||
func TestPortAllocator_AllocatePort(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
mockDB := newMockRQLiteClient()
|
||||
|
||||
@ -20,7 +20,10 @@ import (
|
||||
|
||||
// ConfigGenerator manages generation of node, gateway, and service configs
|
||||
type ConfigGenerator struct {
|
||||
oramaDir string
|
||||
oramaDir string
|
||||
SSHUser string // Operator metadata
|
||||
Environment string
|
||||
OperatorWallet string
|
||||
}
|
||||
|
||||
// NewConfigGenerator creates a new config generator
|
||||
@ -192,6 +195,11 @@ func (cg *ConfigGenerator) GenerateNodeConfig(peerAddresses []string, vpsIP stri
|
||||
// HTTPS is still used for client-facing gateway traffic via autocert
|
||||
// TLS can be enabled manually later if needed for inter-node encryption
|
||||
|
||||
// Operator metadata (set by orama node setup via --ssh-user, --environment, --operator-wallet)
|
||||
data.SSHUser = cg.SSHUser
|
||||
data.Environment = cg.Environment
|
||||
data.OperatorWallet = cg.OperatorWallet
|
||||
|
||||
return templates.RenderNodeConfig(data)
|
||||
}
|
||||
|
||||
|
||||
@ -390,7 +390,17 @@ func (ci *CaddyInstaller) generateCaddyfile(domain, email, acmeEndpoint, baseDom
|
||||
sb.WriteString(fmt.Sprintf("\n%s {\n%s\n reverse_proxy localhost:6001\n}\n", baseDomain, tlsBlock))
|
||||
}
|
||||
|
||||
// HTTP fallback (handles plain HTTP and ACME challenges)
|
||||
// HTTP blocks — serve traffic over plain HTTP so the gateway is reachable
|
||||
// even when TLS certificates are unavailable (e.g., Let's Encrypt rate limits).
|
||||
// Without these, Caddy auto-redirects HTTP→HTTPS for the named domain blocks above.
|
||||
sb.WriteString(fmt.Sprintf("\nhttp://*.%s {\n reverse_proxy localhost:6001\n}\n", domain))
|
||||
sb.WriteString(fmt.Sprintf("\nhttp://%s {\n reverse_proxy localhost:6001\n}\n", domain))
|
||||
if baseDomain != "" && baseDomain != domain {
|
||||
sb.WriteString(fmt.Sprintf("\nhttp://*.%s {\n reverse_proxy localhost:6001\n}\n", baseDomain))
|
||||
sb.WriteString(fmt.Sprintf("\nhttp://%s {\n reverse_proxy localhost:6001\n}\n", baseDomain))
|
||||
}
|
||||
|
||||
// HTTP catch-all fallback (handles remaining plain HTTP traffic)
|
||||
sb.WriteString("\n:80 {\n reverse_proxy localhost:6001\n}\n")
|
||||
|
||||
return sb.String()
|
||||
|
||||
@ -53,6 +53,11 @@ type ProductionSetup struct {
|
||||
serviceController *SystemdController
|
||||
binaryInstaller *BinaryInstaller
|
||||
NodePeerID string // Captured during Phase3 for later display
|
||||
|
||||
// Operator metadata (from --ssh-user, --environment, --operator-wallet flags)
|
||||
SSHUser string
|
||||
Environment string
|
||||
OperatorWallet string
|
||||
}
|
||||
|
||||
// ReadBranchPreference reads the stored branch preference from disk
|
||||
@ -599,6 +604,11 @@ func (ps *ProductionSetup) Phase4GenerateConfigs(peerAddresses []string, vpsIP s
|
||||
ps.logf("Phase 4: Generating configurations...")
|
||||
}
|
||||
|
||||
// Propagate operator metadata to config generator
|
||||
ps.configGenerator.SSHUser = ps.SSHUser
|
||||
ps.configGenerator.Environment = ps.Environment
|
||||
ps.configGenerator.OperatorWallet = ps.OperatorWallet
|
||||
|
||||
// Node config (unified architecture)
|
||||
nodeConfig, err := ps.configGenerator.GenerateNodeConfig(peerAddresses, vpsIP, joinAddress, domain, baseDomain, enableHTTPS)
|
||||
if err != nil {
|
||||
|
||||
@ -86,31 +86,44 @@ func (fp *FilesystemProvisioner) EnsureDirectoryStructure() error {
|
||||
// EnsureOramaUser creates the 'orama' system user and group for running services.
|
||||
// Sets ownership of the orama data directory to the new user.
|
||||
func (fp *FilesystemProvisioner) EnsureOramaUser() error {
|
||||
// Check if user already exists
|
||||
if err := exec.Command("id", "orama").Run(); err == nil {
|
||||
return nil // user already exists
|
||||
}
|
||||
|
||||
// Create system user with no login shell and home at /opt/orama
|
||||
cmd := exec.Command("useradd", "--system", "--no-create-home",
|
||||
"--home-dir", fp.oramaHome, "--shell", "/usr/sbin/nologin", "orama")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to create orama user: %w\n%s", err, string(output))
|
||||
}
|
||||
|
||||
// Set ownership of orama directories
|
||||
chown := exec.Command("chown", "-R", "orama:orama", fp.oramaDir)
|
||||
if output, err := chown.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to chown %s: %w\n%s", fp.oramaDir, err, string(output))
|
||||
}
|
||||
|
||||
// Also chown the bin directory
|
||||
binDir := filepath.Join(fp.oramaHome, "bin")
|
||||
if _, err := os.Stat(binDir); err == nil {
|
||||
chown = exec.Command("chown", "-R", "orama:orama", binDir)
|
||||
if output, err := chown.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to chown %s: %w\n%s", binDir, err, string(output))
|
||||
// Check if user already exists; create if not
|
||||
if err := exec.Command("id", "orama").Run(); err != nil {
|
||||
cmd := exec.Command("useradd", "--system", "--no-create-home",
|
||||
"--home-dir", fp.oramaHome, "--shell", "/usr/sbin/nologin", "orama")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to create orama user: %w\n%s", err, string(output))
|
||||
}
|
||||
|
||||
// Set ownership of orama directories (only on first create)
|
||||
chown := exec.Command("chown", "-R", "orama:orama", fp.oramaDir)
|
||||
if output, err := chown.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to chown %s: %w\n%s", fp.oramaDir, err, string(output))
|
||||
}
|
||||
|
||||
binDir := filepath.Join(fp.oramaHome, "bin")
|
||||
if _, err := os.Stat(binDir); err == nil {
|
||||
chown = exec.Command("chown", "-R", "orama:orama", binDir)
|
||||
if output, err := chown.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to chown %s: %w\n%s", binDir, err, string(output))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always ensure the sudoers rule is up-to-date (handles upgrades too).
|
||||
// Resolve systemctl path to avoid hardcoding /bin vs /usr/bin.
|
||||
systemctlPath, err := exec.LookPath("systemctl")
|
||||
if err != nil {
|
||||
systemctlPath = "/bin/systemctl" // fallback
|
||||
}
|
||||
|
||||
// Grant orama user permission to manage namespace and deployment services.
|
||||
sudoersRule := fmt.Sprintf(
|
||||
"orama ALL=(root) NOPASSWD: %[1]s start orama-namespace-*, %[1]s stop orama-namespace-*, %[1]s enable orama-namespace-*, %[1]s disable orama-namespace-*, %[1]s restart orama-namespace-*, %[1]s start orama-deploy-*, %[1]s stop orama-deploy-*, %[1]s enable orama-deploy-*, %[1]s disable orama-deploy-*, %[1]s restart orama-deploy-*, %[1]s daemon-reload\n",
|
||||
systemctlPath,
|
||||
)
|
||||
sudoersPath := "/etc/sudoers.d/orama-namespaces"
|
||||
if err := os.WriteFile(sudoersPath, []byte(sudoersRule), 0440); err != nil {
|
||||
return fmt.Errorf("failed to write sudoers rule: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@ -19,6 +19,18 @@ ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
RestrictNamespaces=yes`
|
||||
|
||||
// oramaNodeHardening is like oramaServiceHardening but WITHOUT NoNewPrivileges.
|
||||
// The node process (which includes the gateway) needs to use sudo to manage
|
||||
// namespace systemd services. NoNewPrivileges prevents sudo from working.
|
||||
const oramaNodeHardening = `User=orama
|
||||
Group=orama
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
RestrictNamespaces=yes`
|
||||
|
||||
// SystemdServiceGenerator generates systemd unit files
|
||||
type SystemdServiceGenerator struct {
|
||||
oramaHome string
|
||||
@ -233,7 +245,7 @@ OOMScoreAdjust=-500
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, ssg.oramaHome, ssg.oramaDir, configFile, logFile, oramaServiceHardening)
|
||||
`, ssg.oramaHome, ssg.oramaDir, configFile, logFile, oramaNodeHardening)
|
||||
}
|
||||
|
||||
// GenerateVaultService generates the Orama Vault Guardian systemd unit.
|
||||
|
||||
@ -5,6 +5,15 @@ node:
|
||||
data_dir: "{{.DataDir}}"
|
||||
max_connections: 50
|
||||
domain: "{{.Domain}}"
|
||||
{{- if .SSHUser}}
|
||||
ssh_user: "{{.SSHUser}}"
|
||||
{{- end}}
|
||||
{{- if .Environment}}
|
||||
environment: "{{.Environment}}"
|
||||
{{- end}}
|
||||
{{- if .OperatorWallet}}
|
||||
operator_wallet: "{{.OperatorWallet}}"
|
||||
{{- end}}
|
||||
|
||||
database:
|
||||
data_dir: "{{.DataDir}}/rqlite"
|
||||
|
||||
@ -41,6 +41,11 @@ type NodeConfigData struct {
|
||||
NodeKey string // Path to X.509 private key for node-to-node communication
|
||||
NodeCACert string // Path to CA certificate (optional)
|
||||
NodeNoVerify bool // Skip certificate verification (for self-signed certs)
|
||||
|
||||
// Operator metadata — written to dns_nodes during registration
|
||||
SSHUser string // SSH user for remote management
|
||||
Environment string // Environment name (devnet, testnet, etc.)
|
||||
OperatorWallet string // Operator wallet address
|
||||
}
|
||||
|
||||
// GatewayConfigData holds parameters for gateway.yaml rendering
|
||||
|
||||
@ -73,6 +73,10 @@ type JWTClaims struct {
|
||||
Nbf int64 `json:"nbf"`
|
||||
Exp int64 `json:"exp"`
|
||||
Namespace string `json:"namespace"`
|
||||
// Custom holds app-defined claims (e.g. tier, subscription state).
|
||||
// Read by serverless functions via the get_caller_claim host call.
|
||||
// May be nil if the token has no custom claims.
|
||||
Custom map[string]string `json:"custom,omitempty"`
|
||||
}
|
||||
|
||||
// ParseAndVerifyJWT verifies a JWT created by this gateway using kid-based key
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,4 +56,15 @@ type Config struct {
|
||||
SFUPort int // Local SFU signaling port to proxy WebSocket connections to
|
||||
TURNDomain string // TURN server domain for credential generation
|
||||
TURNSecret string // HMAC-SHA1 shared secret for TURN credential generation
|
||||
|
||||
// StealthCDNDomain, when set, makes the WebRTC credentials handler
|
||||
// advertise turns:<StealthCDNDomain>:443 (served by the SNI router).
|
||||
StealthCDNDomain string
|
||||
|
||||
// Push notification configuration. Push is enabled when at least one
|
||||
// provider URL/token is set. Tokens stored in the push_devices table
|
||||
// are encrypted at rest via pkg/secrets using the cluster secret.
|
||||
NtfyBaseURL string // ntfy server URL (e.g. "http://localhost:8080")
|
||||
NtfyAuthToken string // optional bearer token for ntfy
|
||||
ExpoAccessToken string // optional Expo access token
|
||||
}
|
||||
|
||||
@ -19,10 +19,15 @@ import (
|
||||
"github.com/DeBrosOfficial/network/pkg/logging"
|
||||
"github.com/DeBrosOfficial/network/pkg/olric"
|
||||
"github.com/DeBrosOfficial/network/pkg/pubsub"
|
||||
"github.com/DeBrosOfficial/network/pkg/push"
|
||||
pushexpo "github.com/DeBrosOfficial/network/pkg/push/providers/expo"
|
||||
pushntfy "github.com/DeBrosOfficial/network/pkg/push/providers/ntfy"
|
||||
"github.com/DeBrosOfficial/network/pkg/rqlite"
|
||||
"github.com/DeBrosOfficial/network/pkg/serverless"
|
||||
"github.com/DeBrosOfficial/network/pkg/serverless/hostfunctions"
|
||||
"github.com/DeBrosOfficial/network/pkg/serverless/persistent"
|
||||
"github.com/DeBrosOfficial/network/pkg/serverless/triggers"
|
||||
"github.com/DeBrosOfficial/network/pkg/serverless/wsbridge"
|
||||
"github.com/multiformats/go-multiaddr"
|
||||
olriclib "github.com/olric-data/olric"
|
||||
"go.uber.org/zap"
|
||||
@ -63,6 +68,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
|
||||
}
|
||||
|
||||
59
core/pkg/gateway/dependencies_dsn_test.go
Normal file
59
core/pkg/gateway/dependencies_dsn_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -162,6 +162,15 @@ func (m *mockRQLiteClient) Tx(ctx context.Context, fn func(tx rqlite.Tx) error)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRQLiteClient) Batch(ctx context.Context, ops []rqlite.BatchOp) (*rqlite.BatchResult, error) {
|
||||
return &rqlite.BatchResult{Committed: true, Results: make([]rqlite.OpResult, len(ops))}, nil
|
||||
}
|
||||
|
||||
func (m *mockRQLiteClient) BatchWithSeq(ctx context.Context, namespace string, ops []rqlite.BatchOp) (*rqlite.BatchResult, int64, error) {
|
||||
res, err := m.Batch(ctx, ops)
|
||||
return res, 1, err
|
||||
}
|
||||
|
||||
// mockProcessManager implements a mock process manager for testing
|
||||
type mockProcessManager struct {
|
||||
StartFunc func(ctx context.Context, deployment *deployments.Deployment, workDir string) error
|
||||
|
||||
@ -129,6 +129,9 @@ func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1b. Look up the operator wallet from the consumed token (may be empty for legacy tokens)
|
||||
operatorWallet := h.tokenOperatorWallet(ctx, req.Token)
|
||||
|
||||
// 2. Clean up stale WG entries for this public IP (from previous installs).
|
||||
// This prevents ghost peers: old rows with different node_id/wg_key that
|
||||
// the sync loop would keep trying to reach.
|
||||
@ -150,8 +153,8 @@ func (h *Handler) HandleJoin(w http.ResponseWriter, r *http.Request) {
|
||||
// 4. Register WG peer in database
|
||||
nodeID := fmt.Sprintf("node-%s", wgIP) // temporary ID based on WG IP
|
||||
_, err = h.rqliteClient.Exec(ctx,
|
||||
"INSERT OR REPLACE INTO wireguard_peers (node_id, wg_ip, public_key, public_ip, wg_port) VALUES (?, ?, ?, ?, ?)",
|
||||
nodeID, wgIP, req.WGPublicKey, req.PublicIP, 51820)
|
||||
"INSERT OR REPLACE INTO wireguard_peers (node_id, wg_ip, public_key, public_ip, wg_port, operator_wallet) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
nodeID, wgIP, req.WGPublicKey, req.PublicIP, 51820, operatorWallet)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to register WG peer", zap.Error(err))
|
||||
http.Error(w, "failed to register peer", http.StatusInternalServerError)
|
||||
@ -307,6 +310,22 @@ func (h *Handler) consumeToken(ctx context.Context, token, usedByIP string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
// tokenOperatorWallet looks up the operator_wallet from a consumed invite token.
|
||||
// Returns empty string if the token has no operator (legacy tokens).
|
||||
func (h *Handler) tokenOperatorWallet(ctx context.Context, token string) string {
|
||||
var rows []struct {
|
||||
Wallet string `db:"operator_wallet"`
|
||||
}
|
||||
if err := h.rqliteClient.Query(ctx, &rows,
|
||||
"SELECT COALESCE(operator_wallet, '') AS operator_wallet FROM invite_tokens WHERE token = ?", token); err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
return rows[0].Wallet
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// assignWGIP finds the next available 10.0.0.x IP by querying all peers and
|
||||
// finding the numerically highest IP. This avoids lexicographic comparison issues
|
||||
// where MAX("10.0.0.9") > MAX("10.0.0.10") in SQL string comparison.
|
||||
|
||||
99
core/pkg/gateway/handlers/operator/handler.go
Normal file
99
core/pkg/gateway/handlers/operator/handler.go
Normal file
@ -0,0 +1,99 @@
|
||||
// Package operator provides HTTP handlers for node operator management.
|
||||
//
|
||||
// Operators authenticate via wallet JWT (same auth flow as namespaces).
|
||||
// Each operator's nodes are tracked by their wallet address in the
|
||||
// dns_nodes and wireguard_peers tables.
|
||||
package operator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/gateway/auth"
|
||||
"github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys"
|
||||
"github.com/DeBrosOfficial/network/pkg/rqlite"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Handler provides HTTP handlers for operator node management.
|
||||
type Handler struct {
|
||||
logger *zap.Logger
|
||||
rqliteClient rqlite.Client
|
||||
}
|
||||
|
||||
// NewHandler creates an operator handler.
|
||||
func NewHandler(logger *zap.Logger, rqliteClient rqlite.Client) *Handler {
|
||||
return &Handler{
|
||||
logger: logger,
|
||||
rqliteClient: rqliteClient,
|
||||
}
|
||||
}
|
||||
|
||||
// walletFromRequest extracts the operator's wallet address from the request.
|
||||
// Supports both JWT auth (wallet in Sub claim) and API key auth (wallet looked
|
||||
// up from wallet_api_keys table).
|
||||
func (h *Handler) walletFromRequest(r *http.Request) string {
|
||||
// 1. Try JWT claims first (wallet JWT auth sets Sub = "0x...")
|
||||
if claims, ok := r.Context().Value(ctxkeys.JWT).(*auth.JWTClaims); ok && claims != nil {
|
||||
sub := strings.TrimSpace(claims.Sub)
|
||||
if strings.HasPrefix(strings.ToLower(sub), "0x") {
|
||||
return sub
|
||||
}
|
||||
// JWT with API key subject
|
||||
if strings.HasPrefix(strings.ToLower(sub), "ak_") {
|
||||
return h.resolveWalletFromAPIKey(r.Context(), sub)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try API key from context (X-API-Key header, no JWT)
|
||||
if apiKey, ok := r.Context().Value(ctxkeys.APIKey).(string); ok && apiKey != "" {
|
||||
return h.resolveWalletFromAPIKey(r.Context(), apiKey)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveWalletFromAPIKey looks up the wallet address linked to an API key.
|
||||
// It queries namespace_ownership for a wallet-type owner of the namespace.
|
||||
func (h *Handler) resolveWalletFromAPIKey(ctx context.Context, apiKeySub string) string {
|
||||
if h.rqliteClient == nil {
|
||||
return ""
|
||||
}
|
||||
ns := extractNamespace(apiKeySub)
|
||||
if ns == "" {
|
||||
return ""
|
||||
}
|
||||
var rows []struct {
|
||||
OwnerID string `db:"owner_id"`
|
||||
}
|
||||
if err := h.rqliteClient.Query(ctx, &rows,
|
||||
`SELECT no.owner_id FROM namespace_ownership no
|
||||
JOIN namespaces n ON no.namespace_id = n.id
|
||||
WHERE n.name = ? AND no.owner_type = 'wallet'
|
||||
LIMIT 1`,
|
||||
ns); err != nil || len(rows) == 0 {
|
||||
return ""
|
||||
}
|
||||
return rows[0].OwnerID
|
||||
}
|
||||
|
||||
// extractNamespace extracts the namespace from an API key subject like "ak_xxx:namespace".
|
||||
func extractNamespace(apiKeySub string) string {
|
||||
parts := strings.SplitN(apiKeySub, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return apiKeySub
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
242
core/pkg/gateway/handlers/operator/handler_test.go
Normal file
242
core/pkg/gateway/handlers/operator/handler_test.go
Normal file
@ -0,0 +1,242 @@
|
||||
package operator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/gateway/auth"
|
||||
"github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys"
|
||||
)
|
||||
|
||||
func TestWalletFromRequest_withClaims(t *testing.T) {
|
||||
h := NewHandler(nil, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
claims := &auth.JWTClaims{Sub: "0xabc123"}
|
||||
ctx := context.WithValue(r.Context(), ctxkeys.JWT, claims)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
wallet := h.walletFromRequest(r)
|
||||
if wallet != "0xabc123" {
|
||||
t.Errorf("wallet = %q, want %q", wallet, "0xabc123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletFromRequest_noClaims(t *testing.T) {
|
||||
h := NewHandler(nil, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
wallet := h.walletFromRequest(r)
|
||||
if wallet != "" {
|
||||
t.Errorf("wallet = %q, want empty", wallet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletFromRequest_nilClaims(t *testing.T) {
|
||||
h := NewHandler(nil, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
ctx := context.WithValue(r.Context(), ctxkeys.JWT, (*auth.JWTClaims)(nil))
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
wallet := h.walletFromRequest(r)
|
||||
if wallet != "" {
|
||||
t.Errorf("wallet = %q, want empty", wallet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletFromRequest_apiKeyContext(t *testing.T) {
|
||||
// When auth middleware sets ctxkeys.APIKey (no JWT), walletFromRequest
|
||||
// should try to resolve via the API key. With nil rqliteClient it returns
|
||||
// empty (can't query DB), but it shouldn't panic.
|
||||
h := NewHandler(nil, nil)
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
ctx := context.WithValue(r.Context(), ctxkeys.APIKey, "ak_test:myns")
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
// Should not panic — returns empty because no DB to query
|
||||
wallet := h.walletFromRequest(r)
|
||||
if wallet != "" {
|
||||
t.Errorf("wallet = %q, want empty (no DB)", wallet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractNamespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"ak_abc123:myns", "myns"},
|
||||
{"ak_abc123", "ak_abc123"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := extractNamespace(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractNamespace(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSON_valid(t *testing.T) {
|
||||
body := strings.NewReader(`{"node_id":"test-node","environment":"devnet"}`)
|
||||
r := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
|
||||
var req RegisterRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
t.Fatalf("decodeJSON: %v", err)
|
||||
}
|
||||
if req.NodeID != "test-node" {
|
||||
t.Errorf("NodeID = %q, want %q", req.NodeID, "test-node")
|
||||
}
|
||||
if req.Environment != "devnet" {
|
||||
t.Errorf("Environment = %q, want %q", req.Environment, "devnet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeJSON_invalid(t *testing.T) {
|
||||
body := strings.NewReader(`not-json`)
|
||||
r := httptest.NewRequest(http.MethodPost, "/", body)
|
||||
|
||||
var req RegisterRequest
|
||||
if err := decodeJSON(r, &req); err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInvite_noAuth(t *testing.T) {
|
||||
h := NewHandler(nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/operator/invite", nil)
|
||||
|
||||
h.HandleInvite(w, r)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInvite_wrongMethod(t *testing.T) {
|
||||
h := NewHandler(nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/operator/invite", nil)
|
||||
|
||||
h.HandleInvite(w, r)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListNodes_noAuth(t *testing.T) {
|
||||
h := NewHandler(nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/operator/nodes", nil)
|
||||
|
||||
h.HandleListNodes(w, r)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListNodes_wrongMethod(t *testing.T) {
|
||||
h := NewHandler(nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/operator/nodes", nil)
|
||||
|
||||
h.HandleListNodes(w, r)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRegister_noAuth(t *testing.T) {
|
||||
h := NewHandler(nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register", strings.NewReader(`{"node_id":"test"}`))
|
||||
|
||||
h.HandleRegister(w, r)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRegister_missingFields(t *testing.T) {
|
||||
h := NewHandler(nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register", strings.NewReader(`{}`))
|
||||
claims := &auth.JWTClaims{Sub: "0xabc"}
|
||||
r = r.WithContext(context.WithValue(r.Context(), ctxkeys.JWT, claims))
|
||||
|
||||
h.HandleRegister(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRegister_invalidEnvironment(t *testing.T) {
|
||||
h := NewHandler(nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register",
|
||||
strings.NewReader(`{"node_id":"test","environment":"<script>alert(1)</script>"}`))
|
||||
claims := &auth.JWTClaims{Sub: "0xabc"}
|
||||
r = r.WithContext(context.WithValue(r.Context(), ctxkeys.JWT, claims))
|
||||
|
||||
h.HandleRegister(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRegister_invalidRole(t *testing.T) {
|
||||
h := NewHandler(nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, "/v1/operator/node/register",
|
||||
strings.NewReader(`{"node_id":"test","role":"admin"}`))
|
||||
claims := &auth.JWTClaims{Sub: "0xabc"}
|
||||
r = r.WithContext(context.WithValue(r.Context(), ctxkeys.JWT, claims))
|
||||
|
||||
h.HandleRegister(w, r)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedEnvironments(t *testing.T) {
|
||||
valid := []string{"devnet", "testnet", "sandbox", "production", "mainnet"}
|
||||
invalid := []string{"staging", "local", "<script>", ""}
|
||||
|
||||
for _, env := range valid {
|
||||
if !allowedEnvironments[env] {
|
||||
t.Errorf("expected %q to be allowed", env)
|
||||
}
|
||||
}
|
||||
for _, env := range invalid {
|
||||
if allowedEnvironments[env] {
|
||||
t.Errorf("expected %q to be disallowed", env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedRoles(t *testing.T) {
|
||||
valid := []string{"node", "nameserver", "nameserver-ns1", "nameserver-ns2", "nameserver-ns3"}
|
||||
invalid := []string{"admin", "root", ""}
|
||||
|
||||
for _, role := range valid {
|
||||
if !allowedRoles[role] {
|
||||
t.Errorf("expected %q to be allowed", role)
|
||||
}
|
||||
}
|
||||
for _, role := range invalid {
|
||||
if allowedRoles[role] {
|
||||
t.Errorf("expected %q to be disallowed", role)
|
||||
}
|
||||
}
|
||||
}
|
||||
79
core/pkg/gateway/handlers/operator/invite.go
Normal file
79
core/pkg/gateway/handlers/operator/invite.go
Normal file
@ -0,0 +1,79 @@
|
||||
package operator
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// InviteRequest is the optional body for POST /v1/operator/invite.
|
||||
type InviteRequest struct {
|
||||
ExpiryMinutes int `json:"expiry_minutes,omitempty"` // Default: 60
|
||||
}
|
||||
|
||||
// InviteResponse is returned on success.
|
||||
type InviteResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
|
||||
// HandleInvite generates an invite token tagged with the operator's wallet.
|
||||
// Requires wallet JWT authentication.
|
||||
//
|
||||
// POST /v1/operator/invite
|
||||
func (h *Handler) HandleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
wallet := h.walletFromRequest(r)
|
||||
if wallet == "" {
|
||||
writeError(w, http.StatusUnauthorized, "wallet authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse optional expiry from body (default: 60min, max: 7 days).
|
||||
expiryMinutes := 60
|
||||
if r.Body != nil && r.ContentLength > 0 {
|
||||
var req InviteRequest
|
||||
if err := decodeJSON(r, &req); err == nil && req.ExpiryMinutes > 0 {
|
||||
expiryMinutes = req.ExpiryMinutes
|
||||
}
|
||||
}
|
||||
const maxExpiryMinutes = 10080 // 7 days
|
||||
if expiryMinutes > maxExpiryMinutes {
|
||||
expiryMinutes = maxExpiryMinutes
|
||||
}
|
||||
|
||||
// Generate random 32-byte token.
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
h.logger.Error("failed to generate invite token", zap.Error(err))
|
||||
writeError(w, http.StatusInternalServerError, "failed to generate token")
|
||||
return
|
||||
}
|
||||
token := hex.EncodeToString(tokenBytes)
|
||||
|
||||
expiresAt := time.Now().UTC().Add(time.Duration(expiryMinutes) * time.Minute)
|
||||
expiresAtStr := expiresAt.Format("2006-01-02 15:04:05")
|
||||
|
||||
ctx := r.Context()
|
||||
_, err := h.rqliteClient.Exec(ctx,
|
||||
"INSERT INTO invite_tokens (token, created_by, expires_at, operator_wallet) VALUES (?, ?, ?, ?)",
|
||||
token, fmt.Sprintf("operator:%s", wallet), expiresAtStr, wallet)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to store invite token", zap.Error(err))
|
||||
writeError(w, http.StatusInternalServerError, "failed to create invite token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, InviteResponse{
|
||||
Token: token,
|
||||
ExpiresAt: expiresAtStr,
|
||||
})
|
||||
}
|
||||
74
core/pkg/gateway/handlers/operator/nodes.go
Normal file
74
core/pkg/gateway/handlers/operator/nodes.go
Normal file
@ -0,0 +1,74 @@
|
||||
package operator
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// NodeInfo represents a node owned by the operator.
|
||||
type NodeInfo struct {
|
||||
ID string `json:"id" db:"id"`
|
||||
IPAddress string `json:"ip_address" db:"ip_address"`
|
||||
InternalIP string `json:"internal_ip,omitempty" db:"internal_ip"`
|
||||
Environment string `json:"environment,omitempty" db:"environment"`
|
||||
Role string `json:"role,omitempty" db:"role"`
|
||||
SSHUser string `json:"ssh_user,omitempty" db:"ssh_user"`
|
||||
Status string `json:"status" db:"status"`
|
||||
Region string `json:"region,omitempty" db:"region"`
|
||||
LastSeen string `json:"last_seen,omitempty" db:"last_seen"`
|
||||
OperatorWallet string `json:"operator_wallet,omitempty" db:"operator_wallet"`
|
||||
}
|
||||
|
||||
// ListNodesResponse is returned by GET /v1/operator/nodes.
|
||||
type ListNodesResponse struct {
|
||||
Nodes []NodeInfo `json:"nodes"`
|
||||
}
|
||||
|
||||
// HandleListNodes returns all nodes owned by the authenticated operator.
|
||||
// Optionally filtered by ?env=<environment>.
|
||||
//
|
||||
// GET /v1/operator/nodes
|
||||
func (h *Handler) HandleListNodes(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
wallet := h.walletFromRequest(r)
|
||||
if wallet == "" {
|
||||
writeError(w, http.StatusUnauthorized, "wallet authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
envFilter := r.URL.Query().Get("env")
|
||||
|
||||
query := `SELECT id, ip_address, COALESCE(internal_ip, '') AS internal_ip,
|
||||
COALESCE(environment, 'production') AS environment,
|
||||
COALESCE(role, 'node') AS role, COALESCE(ssh_user, 'root') AS ssh_user,
|
||||
status, COALESCE(region, '') AS region, COALESCE(last_seen, '') AS last_seen,
|
||||
COALESCE(operator_wallet, '') AS operator_wallet
|
||||
FROM dns_nodes WHERE operator_wallet = ?`
|
||||
args := []interface{}{wallet}
|
||||
|
||||
if envFilter != "" {
|
||||
query += " AND environment = ?"
|
||||
args = append(args, envFilter)
|
||||
}
|
||||
|
||||
query += " ORDER BY environment, ip_address"
|
||||
|
||||
var nodes []NodeInfo
|
||||
if err := h.rqliteClient.Query(ctx, &nodes, query, args...); err != nil {
|
||||
h.logger.Error("failed to query operator nodes", zap.Error(err))
|
||||
writeError(w, http.StatusInternalServerError, "failed to query nodes")
|
||||
return
|
||||
}
|
||||
|
||||
if nodes == nil {
|
||||
nodes = []NodeInfo{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, ListNodesResponse{Nodes: nodes})
|
||||
}
|
||||
138
core/pkg/gateway/handlers/operator/register.go
Normal file
138
core/pkg/gateway/handlers/operator/register.go
Normal file
@ -0,0 +1,138 @@
|
||||
package operator
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// RegisterRequest is the body for POST /v1/operator/node/register.
|
||||
type RegisterRequest struct {
|
||||
NodeID string `json:"node_id"` // dns_nodes.id (peer ID or hostname)
|
||||
IPAddress string `json:"ip_address,omitempty"` // Public IP (alternative lookup key)
|
||||
Environment string `json:"environment,omitempty"` // e.g., "devnet", "sandbox"
|
||||
Role string `json:"role,omitempty"` // e.g., "node", "nameserver"
|
||||
SSHUser string `json:"ssh_user,omitempty"` // SSH user (default: "root")
|
||||
}
|
||||
|
||||
var (
|
||||
allowedEnvironments = map[string]bool{
|
||||
"production": true, "devnet": true, "testnet": true, "sandbox": true, "mainnet": true,
|
||||
}
|
||||
allowedRoles = map[string]bool{
|
||||
"node": true, "nameserver": true, "nameserver-ns1": true, "nameserver-ns2": true, "nameserver-ns3": true,
|
||||
}
|
||||
)
|
||||
|
||||
// HandleRegister tags an existing node with the operator's wallet.
|
||||
// The node must already exist in dns_nodes and be either unclaimed or
|
||||
// already owned by the requesting operator.
|
||||
//
|
||||
// POST /v1/operator/node/register
|
||||
func (h *Handler) HandleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
wallet := h.walletFromRequest(r)
|
||||
if wallet == "" {
|
||||
writeError(w, http.StatusUnauthorized, "wallet authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
var req RegisterRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.NodeID == "" && req.IPAddress == "" {
|
||||
writeError(w, http.StatusBadRequest, "node_id or ip_address required")
|
||||
return
|
||||
}
|
||||
if req.Environment != "" && !allowedEnvironments[req.Environment] {
|
||||
writeError(w, http.StatusBadRequest, "invalid environment")
|
||||
return
|
||||
}
|
||||
if req.Role != "" && !allowedRoles[req.Role] {
|
||||
writeError(w, http.StatusBadRequest, "invalid role")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Build the UPDATE dynamically based on what fields are provided.
|
||||
setClauses := "operator_wallet = ?"
|
||||
args := []interface{}{wallet}
|
||||
|
||||
if req.Environment != "" {
|
||||
setClauses += ", environment = ?"
|
||||
args = append(args, req.Environment)
|
||||
}
|
||||
if req.Role != "" {
|
||||
setClauses += ", role = ?"
|
||||
args = append(args, req.Role)
|
||||
}
|
||||
if req.SSHUser != "" {
|
||||
setClauses += ", ssh_user = ?"
|
||||
args = append(args, req.SSHUser)
|
||||
}
|
||||
|
||||
setClauses += ", updated_at = datetime('now')"
|
||||
|
||||
// Match by node_id or ip_address. Only allow claiming unclaimed nodes
|
||||
// or nodes already owned by this operator (prevents hijacking).
|
||||
var whereClause string
|
||||
if req.NodeID != "" {
|
||||
whereClause = "id = ? AND (operator_wallet IS NULL OR operator_wallet = '' OR operator_wallet = ?)"
|
||||
args = append(args, req.NodeID, wallet)
|
||||
} else {
|
||||
whereClause = "ip_address = ? AND (operator_wallet IS NULL OR operator_wallet = '' OR operator_wallet = ?)"
|
||||
args = append(args, req.IPAddress, wallet)
|
||||
}
|
||||
|
||||
query := "UPDATE dns_nodes SET " + setClauses + " WHERE " + whereClause
|
||||
result, err := h.rqliteClient.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to register node with operator", zap.Error(err))
|
||||
writeError(w, http.StatusInternalServerError, "failed to register node")
|
||||
return
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
h.logger.Error("failed to check rows affected", zap.Error(err))
|
||||
writeError(w, http.StatusInternalServerError, "failed to register node")
|
||||
return
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
writeError(w, http.StatusNotFound, "node not found or owned by another operator")
|
||||
return
|
||||
}
|
||||
|
||||
// Also update wireguard_peers if we can match by public_ip.
|
||||
if req.IPAddress != "" {
|
||||
if _, err := h.rqliteClient.Exec(ctx,
|
||||
"UPDATE wireguard_peers SET operator_wallet = ? WHERE public_ip = ?",
|
||||
wallet, req.IPAddress); err != nil {
|
||||
h.logger.Warn("failed to update operator_wallet on wireguard_peers", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "registered",
|
||||
"wallet": wallet,
|
||||
"node_id": req.NodeID,
|
||||
})
|
||||
}
|
||||
|
||||
func decodeJSON(r *http.Request, v interface{}) error {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(body, v)
|
||||
}
|
||||
@ -21,10 +21,12 @@ import (
|
||||
|
||||
// mockPubSubClient implements client.PubSubClient for testing
|
||||
type mockPubSubClient struct {
|
||||
PublishFunc func(ctx context.Context, topic string, data []byte) error
|
||||
SubscribeFunc func(ctx context.Context, topic string, handler client.MessageHandler) error
|
||||
UnsubscribeFunc func(ctx context.Context, topic string) error
|
||||
ListTopicsFunc func(ctx context.Context) ([]string, error)
|
||||
PublishFunc func(ctx context.Context, topic string, data []byte) error
|
||||
PublishBatchFunc func(ctx context.Context, msgs []client.TopicMessage, opts client.PublishBatchOptions) error
|
||||
PublishSameFunc func(ctx context.Context, topics []string, data []byte, opts client.PublishBatchOptions) error
|
||||
SubscribeFunc func(ctx context.Context, topic string, handler client.MessageHandler) error
|
||||
UnsubscribeFunc func(ctx context.Context, topic string) error
|
||||
ListTopicsFunc func(ctx context.Context) ([]string, error)
|
||||
}
|
||||
|
||||
func (m *mockPubSubClient) Publish(ctx context.Context, topic string, data []byte) error {
|
||||
@ -34,6 +36,20 @@ func (m *mockPubSubClient) Publish(ctx context.Context, topic string, data []byt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPubSubClient) PublishBatch(ctx context.Context, msgs []client.TopicMessage, opts client.PublishBatchOptions) error {
|
||||
if m.PublishBatchFunc != nil {
|
||||
return m.PublishBatchFunc(ctx, msgs, opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPubSubClient) PublishSame(ctx context.Context, topics []string, data []byte, opts client.PublishBatchOptions) error {
|
||||
if m.PublishSameFunc != nil {
|
||||
return m.PublishSameFunc(ctx, topics, data, opts)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPubSubClient) Subscribe(ctx context.Context, topic string, handler client.MessageHandler) error {
|
||||
if m.SubscribeFunc != nil {
|
||||
return m.SubscribeFunc(ctx, topic, handler)
|
||||
|
||||
156
core/pkg/gateway/handlers/pubsub/publish_batch_handler_test.go
Normal file
156
core/pkg/gateway/handlers/pubsub/publish_batch_handler_test.go
Normal file
@ -0,0 +1,156 @@
|
||||
package pubsub
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/client"
|
||||
)
|
||||
|
||||
func TestPublishBatchHandler_invalid_method(t *testing.T) {
|
||||
h := newTestHandlers(&mockNetworkClient{pubsub: &mockPubSubClient{}})
|
||||
|
||||
req := withNamespace(httptest.NewRequest(http.MethodGet, "/v1/pubsub/publish-batch", nil), "ns")
|
||||
rr := httptest.NewRecorder()
|
||||
h.PublishBatchHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("expected 405, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishBatchHandler_missing_namespace(t *testing.T) {
|
||||
h := newTestHandlers(&mockNetworkClient{pubsub: &mockPubSubClient{}})
|
||||
|
||||
body, _ := json.Marshal(PublishBatchRequest{Messages: []PublishBatchEntry{{Topic: "a", DataB64: "AA=="}}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/pubsub/publish-batch", bytes.NewReader(body))
|
||||
rr := httptest.NewRecorder()
|
||||
h.PublishBatchHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Errorf("expected 403, got %d (body: %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishBatchHandler_empty_messages_rejected(t *testing.T) {
|
||||
h := newTestHandlers(&mockNetworkClient{pubsub: &mockPubSubClient{}})
|
||||
|
||||
body, _ := json.Marshal(PublishBatchRequest{Messages: []PublishBatchEntry{}})
|
||||
req := withNamespace(httptest.NewRequest(http.MethodPost, "/v1/pubsub/publish-batch", bytes.NewReader(body)), "ns")
|
||||
rr := httptest.NewRecorder()
|
||||
h.PublishBatchHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for empty messages, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishBatchHandler_oversize_batch_rejected(t *testing.T) {
|
||||
h := newTestHandlers(&mockNetworkClient{pubsub: &mockPubSubClient{}})
|
||||
|
||||
entries := make([]PublishBatchEntry, MaxPublishBatchSize+1)
|
||||
for i := range entries {
|
||||
entries[i] = PublishBatchEntry{Topic: "t", DataB64: "AA=="}
|
||||
}
|
||||
body, _ := json.Marshal(PublishBatchRequest{Messages: entries})
|
||||
req := withNamespace(httptest.NewRequest(http.MethodPost, "/v1/pubsub/publish-batch", bytes.NewReader(body)), "ns")
|
||||
rr := httptest.NewRecorder()
|
||||
h.PublishBatchHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for oversize batch, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishBatchHandler_invalid_base64_rejected(t *testing.T) {
|
||||
h := newTestHandlers(&mockNetworkClient{pubsub: &mockPubSubClient{}})
|
||||
|
||||
body, _ := json.Marshal(PublishBatchRequest{Messages: []PublishBatchEntry{
|
||||
{Topic: "good", DataB64: base64.StdEncoding.EncodeToString([]byte("ok"))},
|
||||
{Topic: "bad", DataB64: "!!!not-base64"},
|
||||
}})
|
||||
req := withNamespace(httptest.NewRequest(http.MethodPost, "/v1/pubsub/publish-batch", bytes.NewReader(body)), "ns")
|
||||
rr := httptest.NewRecorder()
|
||||
h.PublishBatchHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid base64, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishBatchHandler_missing_topic_rejected(t *testing.T) {
|
||||
h := newTestHandlers(&mockNetworkClient{pubsub: &mockPubSubClient{}})
|
||||
|
||||
body, _ := json.Marshal(PublishBatchRequest{Messages: []PublishBatchEntry{
|
||||
{Topic: "", DataB64: base64.StdEncoding.EncodeToString([]byte("x"))},
|
||||
}})
|
||||
req := withNamespace(httptest.NewRequest(http.MethodPost, "/v1/pubsub/publish-batch", bytes.NewReader(body)), "ns")
|
||||
rr := httptest.NewRecorder()
|
||||
h.PublishBatchHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing topic, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishBatchHandler_happy_calls_PublishBatch(t *testing.T) {
|
||||
var (
|
||||
called int32
|
||||
gotMessages []client.TopicMessage
|
||||
mu sync.Mutex
|
||||
)
|
||||
mock := &mockPubSubClient{
|
||||
PublishBatchFunc: func(ctx context.Context, msgs []client.TopicMessage, opts client.PublishBatchOptions) error {
|
||||
atomic.AddInt32(&called, 1)
|
||||
mu.Lock()
|
||||
gotMessages = append(gotMessages, msgs...)
|
||||
mu.Unlock()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
h := newTestHandlers(&mockNetworkClient{pubsub: mock})
|
||||
|
||||
entries := []PublishBatchEntry{
|
||||
{Topic: "a", DataB64: base64.StdEncoding.EncodeToString([]byte("data-a"))},
|
||||
{Topic: "b", DataB64: base64.StdEncoding.EncodeToString([]byte("data-b"))},
|
||||
}
|
||||
body, _ := json.Marshal(PublishBatchRequest{Messages: entries})
|
||||
req := withNamespace(httptest.NewRequest(http.MethodPost, "/v1/pubsub/publish-batch", bytes.NewReader(body)), "test-ns")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
h.PublishBatchHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// PublishBatch is invoked from a goroutine; give it a moment to run.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for atomic.LoadInt32(&called) == 0 {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("PublishBatch was not called within 2s")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(gotMessages) != 2 {
|
||||
t.Fatalf("expected 2 messages forwarded, got %d", len(gotMessages))
|
||||
}
|
||||
if gotMessages[0].Topic != "a" || string(gotMessages[0].Data) != "data-a" {
|
||||
t.Errorf("unexpected first message: %+v", gotMessages[0])
|
||||
}
|
||||
if gotMessages[1].Topic != "b" || string(gotMessages[1].Data) != "data-b" {
|
||||
t.Errorf("unexpected second message: %+v", gotMessages[1])
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/network/pkg/client"
|
||||
@ -12,6 +13,10 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// MaxPublishBatchSize is the maximum number of messages allowed in a single
|
||||
// /v1/pubsub/publish-batch request. Mirrors pubsub.MaxBatchSize.
|
||||
const MaxPublishBatchSize = pubsub.MaxBatchSize
|
||||
|
||||
// PublishHandler handles POST /v1/pubsub/publish {topic, data_base64}
|
||||
func (p *PubSubHandlers) PublishHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if p.client == nil {
|
||||
@ -39,9 +44,133 @@ func (p *PubSubHandlers) PublishHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for local websocket subscribers FIRST and deliver directly
|
||||
p.deliverLocal(ns, body.Topic, data)
|
||||
|
||||
// Publish to libp2p asynchronously for cross-node delivery.
|
||||
go func() {
|
||||
publishCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
ctx := pubsub.WithNamespace(client.WithInternalAuth(publishCtx), ns)
|
||||
if err := p.client.PubSub().Publish(ctx, body.Topic, data); err != nil {
|
||||
p.logger.ComponentWarn("gateway", "async libp2p publish failed",
|
||||
zap.String("topic", body.Topic),
|
||||
zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
}
|
||||
|
||||
// PublishBatchRequest is the request body for POST /v1/pubsub/publish-batch.
|
||||
type PublishBatchRequest struct {
|
||||
Messages []PublishBatchEntry `json:"messages"`
|
||||
BestEffort bool `json:"best_effort,omitempty"`
|
||||
}
|
||||
|
||||
// PublishBatchEntry is one message in a batch publish request.
|
||||
type PublishBatchEntry struct {
|
||||
Topic string `json:"topic"`
|
||||
DataB64 string `json:"data_base64"`
|
||||
}
|
||||
|
||||
// PublishBatchResponse is the response body for /v1/pubsub/publish-batch.
|
||||
//
|
||||
// libp2p delivery is asynchronous and not awaited here, mirroring the
|
||||
// single-publish handler's fire-and-forget contract. Per-topic failures
|
||||
// are not surfaced via this response — operators should consult logs /
|
||||
// metrics for delivery health.
|
||||
type PublishBatchResponse struct {
|
||||
Status string `json:"status"` // always "ok" — request was accepted
|
||||
}
|
||||
|
||||
// MaxPerMessageBytes caps an individual message payload inside a batch.
|
||||
// Mirrors the 1MB cap on /v1/pubsub/publish.
|
||||
const MaxPerMessageBytes = 1 << 20
|
||||
|
||||
// PublishBatchHandler handles POST /v1/pubsub/publish-batch.
|
||||
// Accepts up to MaxPublishBatchSize messages and publishes them in parallel,
|
||||
// preserving namespace isolation. Local subscribers receive messages
|
||||
// immediately; libp2p delivery is async.
|
||||
func (p *PubSubHandlers) PublishBatchHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if p.client == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "client not initialized")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return
|
||||
}
|
||||
ns := resolveNamespaceFromRequest(r)
|
||||
if ns == "" {
|
||||
writeError(w, http.StatusForbidden, "namespace not resolved")
|
||||
return
|
||||
}
|
||||
|
||||
// Limit body size: MaxPublishBatchSize messages * ~1MB each = up to ~100MB.
|
||||
// Cap conservatively at 16MB to discourage huge payloads.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<20)
|
||||
|
||||
var body PublishBatchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body: expected {messages:[{topic,data_base64}]}")
|
||||
return
|
||||
}
|
||||
if len(body.Messages) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "messages required")
|
||||
return
|
||||
}
|
||||
if len(body.Messages) > MaxPublishBatchSize {
|
||||
writeError(w, http.StatusBadRequest, "too many messages: max is 100 per batch")
|
||||
return
|
||||
}
|
||||
|
||||
// Decode all messages up-front so we can fail fast on bad input.
|
||||
decoded := make([]pubsub.TopicMessage, 0, len(body.Messages))
|
||||
for i, m := range body.Messages {
|
||||
if m.Topic == "" {
|
||||
writeError(w, http.StatusBadRequest, "message missing topic at index "+strconv.Itoa(i))
|
||||
return
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(m.DataB64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid base64 data at index "+strconv.Itoa(i))
|
||||
return
|
||||
}
|
||||
if len(data) > MaxPerMessageBytes {
|
||||
writeError(w, http.StatusBadRequest, "message too large at index "+strconv.Itoa(i))
|
||||
return
|
||||
}
|
||||
decoded = append(decoded, pubsub.TopicMessage{Topic: m.Topic, Data: data})
|
||||
}
|
||||
|
||||
// Deliver locally + dispatch triggers per topic synchronously (fast in-process).
|
||||
for _, msg := range decoded {
|
||||
p.deliverLocal(ns, msg.Topic, msg.Data)
|
||||
}
|
||||
|
||||
// Async libp2p batch publish, similar to PublishHandler's approach.
|
||||
go func() {
|
||||
publishCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
ctx := pubsub.WithNamespace(client.WithInternalAuth(publishCtx), ns)
|
||||
opts := pubsub.PublishBatchOptions{BestEffort: body.BestEffort}
|
||||
err := p.client.PubSub().PublishBatch(ctx, toClientMessages(decoded), clientOpts(opts))
|
||||
if err != nil {
|
||||
p.logger.ComponentWarn("gateway", "async libp2p batch publish failed",
|
||||
zap.Int("messages", len(decoded)),
|
||||
zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusOK, PublishBatchResponse{Status: "ok"})
|
||||
}
|
||||
|
||||
// deliverLocal handles local-subscriber delivery and fires PubSub triggers.
|
||||
// It does NOT publish to libp2p — callers handle that themselves (single
|
||||
// or batched) so this helper stays focused on in-process fan-out.
|
||||
func (p *PubSubHandlers) deliverLocal(ns, topic string, data []byte) {
|
||||
p.mu.RLock()
|
||||
localSubs := p.getLocalSubscribers(body.Topic, ns)
|
||||
localSubs := p.getLocalSubscribers(topic, ns)
|
||||
p.mu.RUnlock()
|
||||
|
||||
localDeliveryCount := 0
|
||||
@ -50,48 +179,38 @@ func (p *PubSubHandlers) PublishHandler(w http.ResponseWriter, r *http.Request)
|
||||
select {
|
||||
case sub.msgChan <- data:
|
||||
localDeliveryCount++
|
||||
p.logger.ComponentDebug("gateway", "delivered to local subscriber",
|
||||
zap.String("topic", body.Topic))
|
||||
default:
|
||||
// Drop if buffer full
|
||||
p.logger.ComponentWarn("gateway", "local subscriber buffer full, dropping message",
|
||||
zap.String("topic", body.Topic))
|
||||
zap.String("topic", topic))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.logger.ComponentInfo("gateway", "pubsub publish: processing message",
|
||||
zap.String("topic", body.Topic),
|
||||
zap.String("topic", topic),
|
||||
zap.String("namespace", ns),
|
||||
zap.Int("data_len", len(data)),
|
||||
zap.Int("local_subscribers", len(localSubs)),
|
||||
zap.Int("local_delivered", localDeliveryCount))
|
||||
|
||||
// Fire PubSub triggers for serverless functions (non-blocking)
|
||||
// Fire PubSub triggers for serverless functions (non-blocking).
|
||||
if p.onPublish != nil {
|
||||
go p.onPublish(context.Background(), ns, body.Topic, data)
|
||||
go p.onPublish(context.Background(), ns, topic, data)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish to libp2p asynchronously for cross-node delivery
|
||||
// This prevents blocking the HTTP response if libp2p network is slow
|
||||
go func() {
|
||||
publishCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
// toClientMessages converts pubsub.TopicMessage to client.TopicMessage for
|
||||
// passing through the PubSubClient interface.
|
||||
func toClientMessages(msgs []pubsub.TopicMessage) []client.TopicMessage {
|
||||
out := make([]client.TopicMessage, len(msgs))
|
||||
for i, m := range msgs {
|
||||
out[i] = client.TopicMessage{Topic: m.Topic, Data: m.Data}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
ctx := pubsub.WithNamespace(client.WithInternalAuth(publishCtx), ns)
|
||||
if err := p.client.PubSub().Publish(ctx, body.Topic, data); err != nil {
|
||||
p.logger.ComponentWarn("gateway", "async libp2p publish failed",
|
||||
zap.String("topic", body.Topic),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
p.logger.ComponentDebug("gateway", "async libp2p publish succeeded",
|
||||
zap.String("topic", body.Topic))
|
||||
}
|
||||
}()
|
||||
|
||||
// Return immediately after local delivery
|
||||
// Local WebSocket subscribers already received the message
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
func clientOpts(o pubsub.PublishBatchOptions) client.PublishBatchOptions {
|
||||
return client.PublishBatchOptions{BestEffort: o.BestEffort, MaxConcurrency: o.MaxConcurrency}
|
||||
}
|
||||
|
||||
// TopicsHandler lists topics within the caller's namespace
|
||||
|
||||
233
core/pkg/gateway/handlers/push/config_handler.go
Normal file
233
core/pkg/gateway/handlers/push/config_handler.go
Normal 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"})
|
||||
}
|
||||
307
core/pkg/gateway/handlers/push/handlers.go
Normal file
307
core/pkg/gateway/handlers/push/handlers.go
Normal 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
|
||||
}
|
||||
330
core/pkg/gateway/handlers/push/handlers_test.go
Normal file
330
core/pkg/gateway/handlers/push/handlers_test.go
Normal file
@ -0,0 +1,330 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
authsvc "github.com/DeBrosOfficial/network/pkg/gateway/auth"
|
||||
"github.com/DeBrosOfficial/network/pkg/gateway/ctxkeys"
|
||||
"github.com/DeBrosOfficial/network/pkg/logging"
|
||||
"github.com/DeBrosOfficial/network/pkg/push"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// fakeStore is an in-memory PushDeviceStore for tests.
|
||||
type fakeStore struct {
|
||||
devices []push.PushDevice
|
||||
upsertFn func(push.PushDevice) error
|
||||
deleteFn func(ns, id string) error
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (s *fakeStore) Upsert(ctx context.Context, dev push.PushDevice) error {
|
||||
if s.upsertFn != nil {
|
||||
return s.upsertFn(dev)
|
||||
}
|
||||
if dev.ID == "" {
|
||||
dev.ID = "row-" + dev.DeviceID
|
||||
}
|
||||
s.devices = append(s.devices, dev)
|
||||
return nil
|
||||
}
|
||||
func (s *fakeStore) Delete(ctx context.Context, ns, id string) error {
|
||||
if s.deleteFn != nil {
|
||||
return s.deleteFn(ns, id)
|
||||
}
|
||||
for i, d := range s.devices {
|
||||
if d.ID == id && d.Namespace == ns {
|
||||
s.devices = append(s.devices[:i], s.devices[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("not found")
|
||||
}
|
||||
func (s *fakeStore) ListForUser(ctx context.Context, ns, userID string) ([]push.PushDevice, error) {
|
||||
if s.listErr != nil {
|
||||
return nil, s.listErr
|
||||
}
|
||||
out := []push.PushDevice{}
|
||||
for _, d := range s.devices {
|
||||
if d.Namespace == ns && d.UserID == userID {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// withAuth populates the namespace + JWT claims (caller user ID).
|
||||
func withAuth(r *http.Request, namespace, userID string) *http.Request {
|
||||
ctx := r.Context()
|
||||
if namespace != "" {
|
||||
ctx = context.WithValue(ctx, ctxkeys.NamespaceOverride, namespace)
|
||||
}
|
||||
if userID != "" {
|
||||
ctx = context.WithValue(ctx, ctxkeys.JWT, &authsvc.JWTClaims{Sub: userID, Namespace: namespace})
|
||||
}
|
||||
return r.WithContext(ctx)
|
||||
}
|
||||
|
||||
func newHandlers(store push.PushDeviceStore, dispatcher *push.PushDispatcher) *Handlers {
|
||||
logger := &logging.ColoredLogger{Logger: zap.NewNop()}
|
||||
return NewHandlers(dispatcher, store, logger)
|
||||
}
|
||||
|
||||
// --- RegisterDeviceHandler ---
|
||||
|
||||
func TestRegister_happy_path(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
h := newHandlers(store, nil)
|
||||
|
||||
body, _ := json.Marshal(RegisterDeviceRequest{
|
||||
DeviceID: "iphone-abc",
|
||||
Provider: "ntfy",
|
||||
Token: "ns/myapp/user-1",
|
||||
Platform: "ios",
|
||||
})
|
||||
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/devices", bytes.NewReader(body)), "myapp", "user-1")
|
||||
rr := httptest.NewRecorder()
|
||||
h.RegisterDeviceHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if len(store.devices) != 1 {
|
||||
t.Fatalf("expected 1 device stored, got %d", len(store.devices))
|
||||
}
|
||||
d := store.devices[0]
|
||||
if d.Namespace != "myapp" || d.UserID != "user-1" || d.Token != "ns/myapp/user-1" {
|
||||
t.Errorf("unexpected device: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_unauthenticated_rejected(t *testing.T) {
|
||||
h := newHandlers(&fakeStore{}, nil)
|
||||
body, _ := json.Marshal(RegisterDeviceRequest{DeviceID: "x", Provider: "ntfy", Token: "t"})
|
||||
|
||||
// No JWT in context.
|
||||
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/devices", bytes.NewReader(body)), "ns", "")
|
||||
rr := httptest.NewRecorder()
|
||||
h.RegisterDeviceHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_unknown_provider_rejected(t *testing.T) {
|
||||
h := newHandlers(&fakeStore{}, nil)
|
||||
body, _ := json.Marshal(RegisterDeviceRequest{DeviceID: "x", Provider: "weirdmail", Token: "t"})
|
||||
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/devices", bytes.NewReader(body)), "ns", "u")
|
||||
rr := httptest.NewRecorder()
|
||||
h.RegisterDeviceHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_oversize_token_rejected(t *testing.T) {
|
||||
h := newHandlers(&fakeStore{}, nil)
|
||||
huge := make([]byte, MaxTokenBytes+1)
|
||||
for i := range huge {
|
||||
huge[i] = 'a'
|
||||
}
|
||||
body, _ := json.Marshal(RegisterDeviceRequest{DeviceID: "x", Provider: "ntfy", Token: string(huge)})
|
||||
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/devices", bytes.NewReader(body)), "ns", "u")
|
||||
rr := httptest.NewRecorder()
|
||||
h.RegisterDeviceHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister_no_store_returns_503(t *testing.T) {
|
||||
h := newHandlers(nil, nil)
|
||||
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/devices", bytes.NewReader([]byte(`{}`))), "ns", "u")
|
||||
rr := httptest.NewRecorder()
|
||||
h.RegisterDeviceHandler(rr, req)
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- ListDevicesHandler ---
|
||||
|
||||
func TestList_returns_only_callers_devices_without_tokens(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
devices: []push.PushDevice{
|
||||
{ID: "1", Namespace: "myapp", UserID: "u1", DeviceID: "d1", Provider: "ntfy", Token: "secret-token-1"},
|
||||
{ID: "2", Namespace: "myapp", UserID: "u1", DeviceID: "d2", Provider: "expo", Token: "secret-token-2"},
|
||||
{ID: "3", Namespace: "myapp", UserID: "u2", DeviceID: "d3", Provider: "ntfy", Token: "secret-token-3"},
|
||||
{ID: "4", Namespace: "other", UserID: "u1", DeviceID: "d4", Provider: "ntfy", Token: "secret-token-4"},
|
||||
},
|
||||
}
|
||||
h := newHandlers(store, nil)
|
||||
|
||||
req := withAuth(httptest.NewRequest(http.MethodGet, "/v1/push/devices", nil), "myapp", "u1")
|
||||
rr := httptest.NewRecorder()
|
||||
h.ListDevicesHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", rr.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Devices []PushDeviceView `json:"devices"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Devices) != 2 {
|
||||
t.Fatalf("expected 2 devices, got %d", len(resp.Devices))
|
||||
}
|
||||
// Tokens must NOT appear in response — they're not even in the struct.
|
||||
if bytes.Contains(rr.Body.Bytes(), []byte("secret-token")) {
|
||||
t.Errorf("response leaked a token: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- DeleteDeviceHandler ---
|
||||
|
||||
func TestDelete_owns_device_succeeds(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
devices: []push.PushDevice{
|
||||
{ID: "row-d1", Namespace: "myapp", UserID: "u1", DeviceID: "d1"},
|
||||
},
|
||||
}
|
||||
h := newHandlers(store, nil)
|
||||
|
||||
req := withAuth(httptest.NewRequest(http.MethodDelete, "/v1/push/devices/row-d1", nil), "myapp", "u1")
|
||||
rr := httptest.NewRecorder()
|
||||
h.DeleteDeviceHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if len(store.devices) != 0 {
|
||||
t.Errorf("expected device removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_other_users_device_returns_404(t *testing.T) {
|
||||
store := &fakeStore{
|
||||
devices: []push.PushDevice{
|
||||
{ID: "row-d1", Namespace: "myapp", UserID: "other-user", DeviceID: "d1"},
|
||||
},
|
||||
}
|
||||
h := newHandlers(store, nil)
|
||||
|
||||
req := withAuth(httptest.NewRequest(http.MethodDelete, "/v1/push/devices/row-d1", nil), "myapp", "u1")
|
||||
rr := httptest.NewRecorder()
|
||||
h.DeleteDeviceHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", rr.Code)
|
||||
}
|
||||
if len(store.devices) != 1 {
|
||||
t.Errorf("expected device NOT removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_missing_id_returns_400(t *testing.T) {
|
||||
h := newHandlers(&fakeStore{}, nil)
|
||||
req := withAuth(httptest.NewRequest(http.MethodDelete, "/v1/push/devices/", nil), "myapp", "u1")
|
||||
rr := httptest.NewRecorder()
|
||||
h.DeleteDeviceHandler(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- SendHandler ---
|
||||
|
||||
func TestSend_dispatcher_called_for_user(t *testing.T) {
|
||||
var sent int32
|
||||
dispatcher := push.New(&fakeStore{
|
||||
devices: []push.PushDevice{
|
||||
{ID: "row-1", Namespace: "myapp", UserID: "target-user", Provider: "fake", Token: "tok"},
|
||||
},
|
||||
}, zap.NewNop())
|
||||
dispatcher.Register(&fakePushProvider{
|
||||
name: "fake",
|
||||
fn: func(ctx context.Context, msg push.PushMessage) error { atomic.AddInt32(&sent, 1); return nil },
|
||||
})
|
||||
|
||||
h := newHandlers(&fakeStore{}, dispatcher)
|
||||
|
||||
body, _ := json.Marshal(SendRequest{
|
||||
UserID: "target-user", Title: "hi", Body: "world",
|
||||
})
|
||||
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/send", bytes.NewReader(body)), "myapp", "u1")
|
||||
rr := httptest.NewRecorder()
|
||||
h.SendHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
if atomic.LoadInt32(&sent) != 1 {
|
||||
t.Errorf("expected provider called once, got %d", sent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSend_no_dispatcher_returns_503(t *testing.T) {
|
||||
h := newHandlers(&fakeStore{}, nil)
|
||||
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/send", bytes.NewReader([]byte(`{"user_id":"u"}`))), "myapp", "u1")
|
||||
rr := httptest.NewRecorder()
|
||||
h.SendHandler(rr, req)
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSend_missing_user_id_returns_400(t *testing.T) {
|
||||
dispatcher := push.New(&fakeStore{}, zap.NewNop())
|
||||
h := newHandlers(&fakeStore{}, dispatcher)
|
||||
|
||||
body, _ := json.Marshal(SendRequest{})
|
||||
req := withAuth(httptest.NewRequest(http.MethodPost, "/v1/push/send", bytes.NewReader(body)), "myapp", "u1")
|
||||
rr := httptest.NewRecorder()
|
||||
h.SendHandler(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
type fakePushProvider struct {
|
||||
name string
|
||||
fn func(ctx context.Context, msg push.PushMessage) error
|
||||
}
|
||||
|
||||
func (p *fakePushProvider) Name() string { return p.name }
|
||||
func (p *fakePushProvider) Send(ctx context.Context, msg push.PushMessage) error {
|
||||
if p.fn != nil {
|
||||
return p.fn(ctx, msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestExtractIDFromPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
path, prefix, want string
|
||||
}{
|
||||
{"/v1/push/devices/abc", "/v1/push/devices/", "abc"},
|
||||
{"/v1/push/devices/abc?x=1", "/v1/push/devices/", "abc"},
|
||||
{"/v1/push/devices/", "/v1/push/devices/", ""},
|
||||
{"/v1/other/abc", "/v1/push/devices/", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := extractIDFromPath(c.path, c.prefix); got != c.want {
|
||||
t.Errorf("extractIDFromPath(%q, %q) = %q, want %q", c.path, c.prefix, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
179
core/pkg/gateway/handlers/push/types.go
Normal file
179
core/pkg/gateway/handlers/push/types.go
Normal 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() }
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user