mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-28 08:54:13 +00:00
Compare commits
No commits in common. "main" and "v0.107.0-nightly" have entirely different histories.
main
...
v0.107.0-n
@ -8,7 +8,7 @@ NOCOLOR='\033[0m'
|
|||||||
|
|
||||||
# Run tests before push
|
# Run tests before push
|
||||||
echo -e "\n${CYAN}Running tests...${NOCOLOR}"
|
echo -e "\n${CYAN}Running tests...${NOCOLOR}"
|
||||||
cd "$(git rev-parse --show-toplevel)/core" && go test ./...
|
go test ./... # Runs all tests in your repo
|
||||||
status=$?
|
status=$?
|
||||||
if [ $status -ne 0 ]; then
|
if [ $status -ne 0 ]; then
|
||||||
echo -e "${RED}Push aborted: some tests failed.${NOCOLOR}"
|
echo -e "${RED}Push aborted: some tests failed.${NOCOLOR}"
|
||||||
91
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
91
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -1,91 +0,0 @@
|
|||||||
name: Bug Report
|
|
||||||
description: Report a bug in Orama Network
|
|
||||||
labels: ["bug"]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for reporting a bug! Please fill out the sections below.
|
|
||||||
|
|
||||||
**Security issues:** If this is a security vulnerability, do NOT open an issue. Email security@orama.io instead.
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: version
|
|
||||||
attributes:
|
|
||||||
label: Orama version
|
|
||||||
description: "Run `orama version` to find this"
|
|
||||||
placeholder: "v0.18.0-beta"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: component
|
|
||||||
attributes:
|
|
||||||
label: Component
|
|
||||||
options:
|
|
||||||
- Gateway / API
|
|
||||||
- CLI (orama command)
|
|
||||||
- WireGuard / Networking
|
|
||||||
- RQLite / Storage
|
|
||||||
- Olric / Caching
|
|
||||||
- IPFS / Pinning
|
|
||||||
- CoreDNS
|
|
||||||
- OramaOS
|
|
||||||
- Other
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: description
|
|
||||||
attributes:
|
|
||||||
label: Description
|
|
||||||
description: A clear description of the bug
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: steps
|
|
||||||
attributes:
|
|
||||||
label: Steps to reproduce
|
|
||||||
description: Minimal steps to reproduce the behavior
|
|
||||||
placeholder: |
|
|
||||||
1. Run `orama ...`
|
|
||||||
2. See error
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: expected
|
|
||||||
attributes:
|
|
||||||
label: Expected behavior
|
|
||||||
description: What you expected to happen
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: actual
|
|
||||||
attributes:
|
|
||||||
label: Actual behavior
|
|
||||||
description: What actually happened (include error messages and logs if any)
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: environment
|
|
||||||
attributes:
|
|
||||||
label: Environment
|
|
||||||
description: OS, Go version, deployment environment, etc.
|
|
||||||
placeholder: |
|
|
||||||
- OS: Ubuntu 22.04
|
|
||||||
- Go: 1.23
|
|
||||||
- Environment: sandbox
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: context
|
|
||||||
attributes:
|
|
||||||
label: Additional context
|
|
||||||
description: Logs, screenshots, monitor reports, or anything else that might help
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
49
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
49
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@ -1,49 +0,0 @@
|
|||||||
name: Feature Request
|
|
||||||
description: Suggest a new feature or improvement
|
|
||||||
labels: ["enhancement"]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Thanks for the suggestion! Please describe what you'd like to see.
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: component
|
|
||||||
attributes:
|
|
||||||
label: Component
|
|
||||||
options:
|
|
||||||
- Gateway / API
|
|
||||||
- CLI (orama command)
|
|
||||||
- WireGuard / Networking
|
|
||||||
- RQLite / Storage
|
|
||||||
- Olric / Caching
|
|
||||||
- IPFS / Pinning
|
|
||||||
- CoreDNS
|
|
||||||
- OramaOS
|
|
||||||
- Other
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: problem
|
|
||||||
attributes:
|
|
||||||
label: Problem
|
|
||||||
description: What problem does this solve? Why do you need it?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: solution
|
|
||||||
attributes:
|
|
||||||
label: Proposed solution
|
|
||||||
description: How do you think this should work?
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: alternatives
|
|
||||||
attributes:
|
|
||||||
label: Alternatives considered
|
|
||||||
description: Any workarounds or alternative approaches you've thought of
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
31
.github/PULL_REQUEST_TEMPLATE.md
vendored
31
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -1,31 +0,0 @@
|
|||||||
## Summary
|
|
||||||
|
|
||||||
<!-- What does this PR do? Keep it to 1-3 bullet points. -->
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
<!-- Why is this change needed? Link to an issue if applicable. -->
|
|
||||||
|
|
||||||
## Test plan
|
|
||||||
|
|
||||||
<!-- How did you verify this works? -->
|
|
||||||
|
|
||||||
- [ ] `make test` passes
|
|
||||||
- [ ] Tested on sandbox/staging environment
|
|
||||||
|
|
||||||
## Distributed system impact
|
|
||||||
|
|
||||||
<!-- Does this change affect any of the following? If yes, explain. -->
|
|
||||||
|
|
||||||
- [ ] Raft quorum / RQLite
|
|
||||||
- [ ] WireGuard mesh / networking
|
|
||||||
- [ ] Olric gossip / caching
|
|
||||||
- [ ] Service startup ordering
|
|
||||||
- [ ] Rolling upgrade compatibility
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
- [ ] Tests added for new functionality or bug fix
|
|
||||||
- [ ] No debug code (`fmt.Println`, `log.Println`) left behind
|
|
||||||
- [ ] Docs updated (if user-facing behavior changed)
|
|
||||||
- [ ] Errors wrapped with context (`fmt.Errorf("...: %w", err)`)
|
|
||||||
80
.github/workflows/publish-sdk.yml
vendored
80
.github/workflows/publish-sdk.yml
vendored
@ -1,80 +0,0 @@
|
|||||||
name: Publish SDK to npm
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version to publish (e.g., 1.0.0). Leave empty to use package.json version."
|
|
||||||
required: false
|
|
||||||
dry-run:
|
|
||||||
description: "Dry run (don't actually publish)"
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
name: Build & Publish @debros/orama
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: sdk
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
registry-url: "https://registry.npmjs.org"
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 9
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Bump version
|
|
||||||
if: inputs.version != ''
|
|
||||||
run: npm version ${{ inputs.version }} --no-git-tag-version
|
|
||||||
|
|
||||||
- name: Typecheck
|
|
||||||
run: pnpm typecheck
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
run: pnpm vitest run tests/unit
|
|
||||||
|
|
||||||
- name: Publish (dry run)
|
|
||||||
if: inputs.dry-run == true
|
|
||||||
run: npm publish --access public --dry-run
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
|
|
||||||
- name: Publish
|
|
||||||
if: inputs.dry-run == false
|
|
||||||
run: npm publish --access public
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
|
|
||||||
- name: Get published version
|
|
||||||
if: inputs.dry-run == false
|
|
||||||
id: version
|
|
||||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Create git tag
|
|
||||||
if: inputs.dry-run == false
|
|
||||||
working-directory: .
|
|
||||||
run: |
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git tag "sdk/v${{ steps.version.outputs.version }}"
|
|
||||||
git push origin "sdk/v${{ steps.version.outputs.version }}"
|
|
||||||
6
.github/workflows/release-apt.yml
vendored
6
.github/workflows/release-apt.yml
vendored
@ -28,8 +28,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.24"
|
go-version: "1.23"
|
||||||
cache-dependency-path: core/go.sum
|
|
||||||
|
|
||||||
- name: Get version
|
- name: Get version
|
||||||
id: version
|
id: version
|
||||||
@ -47,7 +46,6 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Build binary
|
- name: Build binary
|
||||||
working-directory: core
|
|
||||||
env:
|
env:
|
||||||
GOARCH: ${{ matrix.arch }}
|
GOARCH: ${{ matrix.arch }}
|
||||||
CGO_ENABLED: 0
|
CGO_ENABLED: 0
|
||||||
@ -73,7 +71,7 @@ jobs:
|
|||||||
mkdir -p ${PKG_NAME}/usr/local/bin
|
mkdir -p ${PKG_NAME}/usr/local/bin
|
||||||
|
|
||||||
# Copy binaries
|
# Copy binaries
|
||||||
cp core/build/usr/local/bin/* ${PKG_NAME}/usr/local/bin/
|
cp build/usr/local/bin/* ${PKG_NAME}/usr/local/bin/
|
||||||
chmod 755 ${PKG_NAME}/usr/local/bin/*
|
chmod 755 ${PKG_NAME}/usr/local/bin/*
|
||||||
|
|
||||||
# Create control file
|
# Create control file
|
||||||
|
|||||||
4
.github/workflows/release.yaml
vendored
4
.github/workflows/release.yaml
vendored
@ -23,8 +23,8 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: '1.21'
|
||||||
cache-dependency-path: core/go.sum
|
cache: true
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v5
|
uses: goreleaser/goreleaser-action@v5
|
||||||
|
|||||||
164
.gitignore
vendored
164
.gitignore
vendored
@ -1,90 +1,112 @@
|
|||||||
# === Global ===
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# Built binaries
|
||||||
|
bin/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# IDE and editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.codex/
|
||||||
|
redeploy-6.sh
|
||||||
.DS_Store?
|
.DS_Store?
|
||||||
._*
|
._*
|
||||||
.Spotlight-V100
|
.Spotlight-V100
|
||||||
.Trashes
|
.Trashes
|
||||||
ehthumbs.db
|
ehthumbs.db
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# IDE
|
# Log files
|
||||||
.vscode/
|
*.log
|
||||||
.idea/
|
|
||||||
.cursor/
|
|
||||||
|
|
||||||
# Environment & credentials
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.local
|
||||||
!.env.example
|
.env.*.local
|
||||||
.mcp.json
|
|
||||||
.claude/
|
|
||||||
.codex/
|
|
||||||
|
|
||||||
# === Core (Go) ===
|
# E2E test config (contains production credentials)
|
||||||
core/phantom-auth/
|
e2e/config.yaml
|
||||||
core/bin/
|
|
||||||
core/bin-linux/
|
|
||||||
core/dist/
|
|
||||||
core/orama-cli-linux
|
|
||||||
core/keys_backup/
|
|
||||||
core/.gocache/
|
|
||||||
core/configs/
|
|
||||||
core/data/*
|
|
||||||
core/tmp/
|
|
||||||
core/temp/
|
|
||||||
core/results/
|
|
||||||
core/rnd/
|
|
||||||
core/vps.txt
|
|
||||||
core/coverage.txt
|
|
||||||
core/coverage.html
|
|
||||||
core/profile.out
|
|
||||||
core/e2e/config.yaml
|
|
||||||
core/scripts/remote-nodes.conf
|
|
||||||
|
|
||||||
# Go build artifacts
|
# Temporary files
|
||||||
*.exe
|
tmp/
|
||||||
*.exe~
|
temp/
|
||||||
*.dll
|
*.tmp
|
||||||
*.so
|
|
||||||
*.dylib
|
# Coverage reports
|
||||||
*.test
|
coverage.txt
|
||||||
*.out
|
coverage.html
|
||||||
|
profile.out
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
*.deb
|
*.deb
|
||||||
*.rpm
|
*.rpm
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.zip
|
*.zip
|
||||||
go.work
|
|
||||||
|
|
||||||
# Logs
|
# Local development files
|
||||||
*.log
|
|
||||||
|
|
||||||
# Databases
|
|
||||||
*.db
|
|
||||||
|
|
||||||
# === Website ===
|
|
||||||
website/node_modules/
|
|
||||||
website/dist/
|
|
||||||
website/invest-api/invest-api
|
|
||||||
website/invest-api/*.db
|
|
||||||
website/invest-api/*.db-shm
|
|
||||||
website/invest-api/*.db-wal
|
|
||||||
|
|
||||||
# === SDK (TypeScript) ===
|
|
||||||
sdk/node_modules/
|
|
||||||
sdk/dist/
|
|
||||||
sdk/coverage/
|
|
||||||
|
|
||||||
# === Vault (Zig) ===
|
|
||||||
vault/.zig-cache/
|
|
||||||
vault/zig-out/
|
|
||||||
|
|
||||||
# === OS ===
|
|
||||||
os/output/
|
|
||||||
|
|
||||||
# === Local development ===
|
|
||||||
.dev/
|
|
||||||
.local/
|
.local/
|
||||||
local/
|
local/
|
||||||
|
|
||||||
|
data/*
|
||||||
|
./bootstrap
|
||||||
|
./node
|
||||||
|
data/bootstrap/rqlite/
|
||||||
|
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
configs/
|
||||||
|
|
||||||
|
.dev/
|
||||||
|
|
||||||
|
.gocache/
|
||||||
|
|
||||||
|
.claude/
|
||||||
|
.mcp.json
|
||||||
|
.cursor/
|
||||||
|
|
||||||
|
# Remote node credentials
|
||||||
|
scripts/remote-nodes.conf
|
||||||
|
|
||||||
|
orama-cli-linux
|
||||||
|
|
||||||
|
rnd/
|
||||||
|
|
||||||
|
keys_backup/
|
||||||
|
|
||||||
|
vps.txt
|
||||||
|
|
||||||
|
bin-linux/
|
||||||
|
|
||||||
|
website/
|
||||||
|
|
||||||
|
terms-agreement
|
||||||
|
|
||||||
|
./cli
|
||||||
|
./inspector
|
||||||
|
|
||||||
|
results/
|
||||||
|
|
||||||
|
phantom-auth/
|
||||||
@ -9,13 +9,11 @@ env:
|
|||||||
|
|
||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- cmd: go mod tidy
|
- go mod tidy
|
||||||
dir: core
|
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
# orama CLI binary
|
# orama CLI binary
|
||||||
- id: orama
|
- id: orama
|
||||||
dir: core
|
|
||||||
main: ./cmd/cli
|
main: ./cmd/cli
|
||||||
binary: orama
|
binary: orama
|
||||||
goos:
|
goos:
|
||||||
@ -33,7 +31,6 @@ builds:
|
|||||||
|
|
||||||
# orama-node binary (Linux only for apt)
|
# orama-node binary (Linux only for apt)
|
||||||
- id: orama-node
|
- id: orama-node
|
||||||
dir: core
|
|
||||||
main: ./cmd/node
|
main: ./cmd/node
|
||||||
binary: orama-node
|
binary: orama-node
|
||||||
goos:
|
goos:
|
||||||
@ -87,7 +84,7 @@ nfpms:
|
|||||||
section: utils
|
section: utils
|
||||||
priority: optional
|
priority: optional
|
||||||
contents:
|
contents:
|
||||||
- src: ./core/README.md
|
- src: ./README.md
|
||||||
dst: /usr/share/doc/orama/README.md
|
dst: /usr/share/doc/orama/README.md
|
||||||
deb:
|
deb:
|
||||||
lintian_overrides:
|
lintian_overrides:
|
||||||
@ -109,7 +106,7 @@ nfpms:
|
|||||||
section: net
|
section: net
|
||||||
priority: optional
|
priority: optional
|
||||||
contents:
|
contents:
|
||||||
- src: ./core/README.md
|
- src: ./README.md
|
||||||
dst: /usr/share/doc/orama-node/README.md
|
dst: /usr/share/doc/orama-node/README.md
|
||||||
deb:
|
deb:
|
||||||
lintian_overrides:
|
lintian_overrides:
|
||||||
|
|||||||
@ -1,78 +1,47 @@
|
|||||||
# Contributing to Orama Network
|
# Contributing to DeBros Network
|
||||||
|
|
||||||
Thanks for helping improve the network! This monorepo contains multiple projects — pick the one relevant to your contribution.
|
Thanks for helping improve the network! This guide covers setup, local dev, tests, and PR guidelines.
|
||||||
|
|
||||||
## Repository Structure
|
## Requirements
|
||||||
|
|
||||||
| Package | Language | Build |
|
- Go 1.22+ (1.23 recommended)
|
||||||
|---------|----------|-------|
|
- RQLite (optional for local runs; the Makefile starts nodes with embedded setup)
|
||||||
| `core/` | Go 1.24+ | `make core-build` |
|
- Make (optional)
|
||||||
| `website/` | TypeScript (pnpm) | `make website-build` |
|
|
||||||
| `vault/` | Zig 0.14+ | `make vault-build` |
|
|
||||||
| `os/` | Go + Buildroot | `make os-build` |
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/DeBrosOfficial/network.git
|
git clone https://github.com/DeBrosOfficial/network.git
|
||||||
cd network
|
cd network
|
||||||
```
|
|
||||||
|
|
||||||
### Core (Go)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd core
|
|
||||||
make deps
|
make deps
|
||||||
make build
|
|
||||||
make test
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Website
|
## Build, Test, Lint
|
||||||
|
|
||||||
|
- Build: `make build`
|
||||||
|
- Test: `make test`
|
||||||
|
- Format/Vet: `make fmt vet` (or `make lint`)
|
||||||
|
|
||||||
|
````
|
||||||
|
|
||||||
|
Useful CLI commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd website
|
./bin/orama health
|
||||||
pnpm install
|
./bin/orama peers
|
||||||
pnpm dev
|
./bin/orama status
|
||||||
```
|
````
|
||||||
|
|
||||||
### Vault (Zig)
|
## Versioning
|
||||||
|
|
||||||
```bash
|
- The CLI reports its version via `orama version`.
|
||||||
cd vault
|
- Releases are tagged (e.g., `v0.18.0-beta`) and published via GoReleaser.
|
||||||
zig build
|
|
||||||
zig build test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Pull Requests
|
## Pull Requests
|
||||||
|
|
||||||
1. Fork and create a topic branch from `main`.
|
1. Fork and create a topic branch.
|
||||||
2. Ensure `make test` passes for affected packages.
|
2. Ensure `make build test` passes; include tests for new functionality.
|
||||||
3. Include tests for new functionality or bug fixes.
|
3. Keep PRs focused and well-described (motivation, approach, testing).
|
||||||
4. Keep PRs focused — one concern per PR.
|
4. Update README/docs for behavior changes.
|
||||||
5. Write a clear description: motivation, approach, and how you tested it.
|
|
||||||
6. Update docs if you're changing user-facing behavior.
|
|
||||||
|
|
||||||
## Code Style
|
|
||||||
|
|
||||||
### Go (core/, os/)
|
|
||||||
|
|
||||||
- Follow standard Go conventions
|
|
||||||
- Run `make lint` before submitting
|
|
||||||
- Wrap errors with context: `fmt.Errorf("failed to X: %w", err)`
|
|
||||||
- No magic values — use named constants
|
|
||||||
|
|
||||||
### TypeScript (website/)
|
|
||||||
|
|
||||||
- TypeScript strict mode
|
|
||||||
- Follow existing patterns in the codebase
|
|
||||||
|
|
||||||
### Zig (vault/)
|
|
||||||
|
|
||||||
- Follow standard Zig conventions
|
|
||||||
- Run `zig build test` before submitting
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
If you find a security vulnerability, **do not open a public issue**. Email security@debros.io instead.
|
|
||||||
|
|
||||||
Thank you for contributing!
|
Thank you for contributing!
|
||||||
|
|||||||
204
Makefile
204
Makefile
@ -1,66 +1,176 @@
|
|||||||
# Orama Monorepo
|
TEST?=./...
|
||||||
# Delegates to sub-project Makefiles
|
|
||||||
|
|
||||||
.PHONY: help build test clean
|
.PHONY: test
|
||||||
|
test:
|
||||||
|
@echo Running tests...
|
||||||
|
go test -v $(TEST)
|
||||||
|
|
||||||
# === Core (Go network) ===
|
# Gateway-focused E2E tests assume gateway and nodes are already running
|
||||||
.PHONY: core core-build core-test core-clean core-lint
|
# Auto-discovers configuration from ~/.orama and queries database for API key
|
||||||
core: core-build
|
# No environment variables required
|
||||||
|
.PHONY: test-e2e test-e2e-deployments test-e2e-fullstack test-e2e-https test-e2e-quick test-e2e-prod test-e2e-shared test-e2e-cluster test-e2e-integration test-e2e-production
|
||||||
|
|
||||||
core-build:
|
# Production E2E tests - includes production-only tests
|
||||||
$(MAKE) -C core build
|
test-e2e-prod:
|
||||||
|
@if [ -z "$$ORAMA_GATEWAY_URL" ]; then \
|
||||||
|
echo "❌ ORAMA_GATEWAY_URL not set"; \
|
||||||
|
echo "Usage: ORAMA_GATEWAY_URL=https://dbrs.space make test-e2e-prod"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@echo "Running E2E tests (including production-only) against $$ORAMA_GATEWAY_URL..."
|
||||||
|
go test -v -tags "e2e production" -timeout 30m ./e2e/...
|
||||||
|
|
||||||
core-test:
|
# Generic e2e target
|
||||||
$(MAKE) -C core test
|
test-e2e:
|
||||||
|
@echo "Running comprehensive E2E tests..."
|
||||||
|
@echo "Auto-discovering configuration from ~/.orama..."
|
||||||
|
go test -v -tags e2e -timeout 30m ./e2e/...
|
||||||
|
|
||||||
core-lint:
|
test-e2e-deployments:
|
||||||
$(MAKE) -C core lint
|
@echo "Running deployment E2E tests..."
|
||||||
|
go test -v -tags e2e -timeout 15m ./e2e/deployments/...
|
||||||
|
|
||||||
core-clean:
|
test-e2e-fullstack:
|
||||||
$(MAKE) -C core clean
|
@echo "Running fullstack E2E tests..."
|
||||||
|
go test -v -tags e2e -timeout 20m -run "TestFullStack" ./e2e/...
|
||||||
|
|
||||||
# === Website ===
|
test-e2e-https:
|
||||||
.PHONY: website website-dev website-build
|
@echo "Running HTTPS/external access E2E tests..."
|
||||||
website-dev:
|
go test -v -tags e2e -timeout 10m -run "TestHTTPS" ./e2e/...
|
||||||
cd website && pnpm dev
|
|
||||||
|
|
||||||
website-build:
|
test-e2e-shared:
|
||||||
cd website && pnpm build
|
@echo "Running shared E2E tests..."
|
||||||
|
go test -v -tags e2e -timeout 10m ./e2e/shared/...
|
||||||
|
|
||||||
# === SDK (TypeScript) ===
|
test-e2e-cluster:
|
||||||
.PHONY: sdk sdk-build sdk-test
|
@echo "Running cluster E2E tests..."
|
||||||
sdk: sdk-build
|
go test -v -tags e2e -timeout 15m ./e2e/cluster/...
|
||||||
|
|
||||||
sdk-build:
|
test-e2e-integration:
|
||||||
cd sdk && pnpm install && pnpm build
|
@echo "Running integration E2E tests..."
|
||||||
|
go test -v -tags e2e -timeout 20m ./e2e/integration/...
|
||||||
|
|
||||||
sdk-test:
|
test-e2e-production:
|
||||||
cd sdk && pnpm test
|
@echo "Running production-only E2E tests..."
|
||||||
|
go test -v -tags "e2e production" -timeout 15m ./e2e/production/...
|
||||||
|
|
||||||
# === Vault (Zig) ===
|
test-e2e-quick:
|
||||||
.PHONY: vault vault-build vault-test
|
@echo "Running quick E2E smoke tests..."
|
||||||
vault-build:
|
go test -v -tags e2e -timeout 5m -run "TestStatic|TestHealth" ./e2e/...
|
||||||
cd vault && zig build
|
|
||||||
|
|
||||||
vault-test:
|
# Network - Distributed P2P Database System
|
||||||
cd vault && zig build test
|
# Makefile for development and build tasks
|
||||||
|
|
||||||
# === OS ===
|
.PHONY: build clean test deps tidy fmt vet lint install-hooks redeploy-devnet redeploy-testnet release health
|
||||||
.PHONY: os os-build
|
|
||||||
os-build:
|
|
||||||
$(MAKE) -C os
|
|
||||||
|
|
||||||
# === Aggregate ===
|
VERSION := 0.107.0
|
||||||
build: core-build
|
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||||
test: core-test
|
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
clean: core-clean
|
LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' -X 'main.date=$(DATE)'
|
||||||
|
LDFLAGS_LINUX := -s -w $(LDFLAGS)
|
||||||
|
|
||||||
|
# Build targets
|
||||||
|
build: deps
|
||||||
|
@echo "Building network executables (version=$(VERSION))..."
|
||||||
|
@mkdir -p bin
|
||||||
|
go build -ldflags "$(LDFLAGS)" -o bin/identity ./cmd/identity
|
||||||
|
go build -ldflags "$(LDFLAGS)" -o bin/orama-node ./cmd/node
|
||||||
|
go build -ldflags "$(LDFLAGS)" -o bin/orama ./cmd/cli/
|
||||||
|
# Inject gateway build metadata via pkg path variables
|
||||||
|
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
|
||||||
|
@echo "Build complete! Run ./bin/orama version"
|
||||||
|
|
||||||
|
# Cross-compile CLI for Linux (only binary needed locally; VPS builds everything else from source)
|
||||||
|
build-linux: deps
|
||||||
|
@echo "Cross-compiling CLI for linux/amd64 (version=$(VERSION))..."
|
||||||
|
@mkdir -p bin-linux
|
||||||
|
GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS_LINUX)" -trimpath -o bin-linux/orama ./cmd/cli/
|
||||||
|
@echo "✓ CLI built at bin-linux/orama"
|
||||||
|
@echo ""
|
||||||
|
@echo "Next steps:"
|
||||||
|
@echo " ./scripts/generate-source-archive.sh"
|
||||||
|
@echo " ./bin/orama install --vps-ip <ip> --nameserver --domain ..."
|
||||||
|
|
||||||
|
# Install git hooks
|
||||||
|
install-hooks:
|
||||||
|
@echo "Installing git hooks..."
|
||||||
|
@bash scripts/install-hooks.sh
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning build artifacts..."
|
||||||
|
rm -rf bin/
|
||||||
|
rm -rf data/
|
||||||
|
@echo "Clean complete!"
|
||||||
|
|
||||||
|
# Deploy to devnet (build + rolling upgrade all nodes)
|
||||||
|
redeploy-devnet:
|
||||||
|
@bash scripts/redeploy.sh --devnet
|
||||||
|
|
||||||
|
# Deploy to devnet without rebuilding
|
||||||
|
redeploy-devnet-quick:
|
||||||
|
@bash scripts/redeploy.sh --devnet --no-build
|
||||||
|
|
||||||
|
# Deploy to testnet (build + rolling upgrade all nodes)
|
||||||
|
redeploy-testnet:
|
||||||
|
@bash scripts/redeploy.sh --testnet
|
||||||
|
|
||||||
|
# Deploy to testnet without rebuilding
|
||||||
|
redeploy-testnet-quick:
|
||||||
|
@bash scripts/redeploy.sh --testnet --no-build
|
||||||
|
|
||||||
|
# Interactive release workflow (tag + push)
|
||||||
|
release:
|
||||||
|
@bash scripts/release.sh
|
||||||
|
|
||||||
|
# Check health of all nodes in an environment
|
||||||
|
# Usage: make health ENV=devnet
|
||||||
|
health:
|
||||||
|
@if [ -z "$(ENV)" ]; then \
|
||||||
|
echo "Usage: make health ENV=devnet|testnet"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@while IFS='|' read -r env host pass role key; do \
|
||||||
|
[ -z "$$env" ] && continue; \
|
||||||
|
case "$$env" in \#*) continue;; esac; \
|
||||||
|
env="$$(echo "$$env" | xargs)"; \
|
||||||
|
[ "$$env" != "$(ENV)" ] && continue; \
|
||||||
|
role="$$(echo "$$role" | xargs)"; \
|
||||||
|
bash scripts/check-node-health.sh "$$host" "$$pass" "$$host ($$role)"; \
|
||||||
|
done < scripts/remote-nodes.conf
|
||||||
|
|
||||||
|
# Help
|
||||||
help:
|
help:
|
||||||
@echo "Orama Monorepo"
|
@echo "Available targets:"
|
||||||
|
@echo " build - Build all executables"
|
||||||
|
@echo " clean - Clean build artifacts"
|
||||||
|
@echo " test - Run unit tests"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " Core (Go): make core-build | core-test | core-lint | core-clean"
|
@echo "E2E Testing:"
|
||||||
@echo " Website: make website-dev | website-build"
|
@echo " make test-e2e-prod - Run all E2E tests incl. production-only (needs ORAMA_GATEWAY_URL)"
|
||||||
@echo " Vault (Zig): make vault-build | vault-test"
|
@echo " make test-e2e-shared - Run shared E2E tests (cache, storage, pubsub, auth)"
|
||||||
@echo " OS: make os-build"
|
@echo " make test-e2e-cluster - Run cluster E2E tests (libp2p, olric, rqlite, namespace)"
|
||||||
|
@echo " make test-e2e-integration - Run integration E2E tests (fullstack, persistence, concurrency)"
|
||||||
|
@echo " make test-e2e-deployments - Run deployment E2E tests"
|
||||||
|
@echo " make test-e2e-production - Run production-only E2E tests (DNS, HTTPS, cross-node)"
|
||||||
|
@echo " make test-e2e-quick - Quick smoke tests (static deploys, health checks)"
|
||||||
|
@echo " make test-e2e - Generic E2E tests (auto-discovers config)"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " Aggregate: make build | test | clean (delegates to core)"
|
@echo " Example:"
|
||||||
|
@echo " ORAMA_GATEWAY_URL=https://orama-devnet.network make test-e2e-prod"
|
||||||
|
@echo ""
|
||||||
|
@echo "Deployment:"
|
||||||
|
@echo " make redeploy-devnet - Build + rolling deploy to all devnet nodes"
|
||||||
|
@echo " make redeploy-devnet-quick - Deploy to devnet without rebuilding"
|
||||||
|
@echo " make redeploy-testnet - Build + rolling deploy to all testnet nodes"
|
||||||
|
@echo " make redeploy-testnet-quick- Deploy to testnet without rebuilding"
|
||||||
|
@echo " make health ENV=devnet - Check health of all nodes in an environment"
|
||||||
|
@echo " make release - Interactive release workflow (tag + push)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Maintenance:"
|
||||||
|
@echo " deps - Download dependencies"
|
||||||
|
@echo " tidy - Tidy dependencies"
|
||||||
|
@echo " fmt - Format code"
|
||||||
|
@echo " vet - Vet code"
|
||||||
|
@echo " lint - Lint code (fmt + vet)"
|
||||||
|
@echo " help - Show this help"
|
||||||
|
|||||||
494
README.md
494
README.md
@ -1,50 +1,474 @@
|
|||||||
# Orama Network
|
# Orama Network - Distributed P2P Platform
|
||||||
|
|
||||||
A decentralized infrastructure platform combining distributed SQL, IPFS storage, caching, serverless WASM execution, and privacy relay — all managed through a unified API gateway.
|
A high-performance API Gateway and distributed platform built in Go. Provides a unified HTTP/HTTPS API for distributed SQL (RQLite), distributed caching (Olric), decentralized storage (IPFS), pub/sub messaging, and serverless WebAssembly execution.
|
||||||
|
|
||||||
## Packages
|
**Architecture:** Modular Gateway / Edge Proxy following SOLID principles
|
||||||
|
|
||||||
| Package | Language | Description |
|
## Features
|
||||||
|---------|----------|-------------|
|
|
||||||
| [core/](core/) | Go | API gateway, distributed node, CLI, and client SDK |
|
- **🔐 Authentication** - Wallet signatures, API keys, JWT tokens
|
||||||
| [sdk/](sdk/) | TypeScript | `@debros/orama` — JavaScript/TypeScript SDK ([npm](https://www.npmjs.com/package/@debros/orama)) |
|
- **💾 Storage** - IPFS-based decentralized file storage with encryption
|
||||||
| [website/](website/) | TypeScript | Marketing website and invest portal |
|
- **⚡ Cache** - Distributed cache with Olric (in-memory key-value)
|
||||||
| [vault/](vault/) | Zig | Distributed secrets vault (Shamir's Secret Sharing) |
|
- **🗄️ Database** - RQLite distributed SQL with Raft consensus + Per-namespace SQLite databases
|
||||||
| [os/](os/) | Go + Buildroot | OramaOS — hardened minimal Linux for network nodes |
|
- **📡 Pub/Sub** - Real-time messaging via LibP2P and WebSocket
|
||||||
|
- **⚙️ Serverless** - WebAssembly function execution with host functions
|
||||||
|
- **🌐 HTTP Gateway** - Unified REST API with automatic HTTPS (Let's Encrypt)
|
||||||
|
- **📦 Client SDK** - Type-safe Go SDK for all services
|
||||||
|
- **🚀 App Deployments** - Deploy React, Next.js, Go, Node.js apps with automatic domains
|
||||||
|
- **🗄️ SQLite Databases** - Per-namespace isolated databases with IPFS backups
|
||||||
|
|
||||||
|
## Application Deployments
|
||||||
|
|
||||||
|
Deploy full-stack applications with automatic domain assignment and namespace isolation.
|
||||||
|
|
||||||
|
### Deploy a React App
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build your app
|
||||||
|
cd my-react-app
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Deploy to Orama Network
|
||||||
|
orama deploy static ./dist --name my-app
|
||||||
|
|
||||||
|
# Your app is now live at: https://my-app.orama.network
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy Next.js with SSR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd my-nextjs-app
|
||||||
|
|
||||||
|
# Ensure next.config.js has: output: 'standalone'
|
||||||
|
npm run build
|
||||||
|
orama deploy nextjs . --name my-nextjs --ssr
|
||||||
|
|
||||||
|
# Live at: https://my-nextjs.orama.network
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy Go Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build for Linux (name binary 'app' for auto-detection)
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o app main.go
|
||||||
|
|
||||||
|
# Deploy (must implement /health endpoint)
|
||||||
|
orama deploy go ./app --name my-api
|
||||||
|
|
||||||
|
# API live at: https://my-api.orama.network
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create SQLite Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create database
|
||||||
|
orama db create my-database
|
||||||
|
|
||||||
|
# Create schema
|
||||||
|
orama db query my-database "CREATE TABLE users (id INT, name TEXT)"
|
||||||
|
|
||||||
|
# Insert data
|
||||||
|
orama db query my-database "INSERT INTO users VALUES (1, 'Alice')"
|
||||||
|
|
||||||
|
# Query data
|
||||||
|
orama db query my-database "SELECT * FROM users"
|
||||||
|
|
||||||
|
# Backup to IPFS
|
||||||
|
orama db backup my-database
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full-Stack Example
|
||||||
|
|
||||||
|
Deploy a complete app with React frontend, Go backend, and SQLite database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Create database
|
||||||
|
orama db create myapp-db
|
||||||
|
orama db query myapp-db "CREATE TABLE users (id INT PRIMARY KEY, name TEXT)"
|
||||||
|
|
||||||
|
# 2. Deploy Go backend (connects to database)
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o api main.go
|
||||||
|
orama deploy go ./api --name myapp-api
|
||||||
|
|
||||||
|
# 3. Deploy React frontend (calls backend API)
|
||||||
|
cd frontend && npm run build
|
||||||
|
orama deploy static ./dist --name myapp
|
||||||
|
|
||||||
|
# Access:
|
||||||
|
# Frontend: https://myapp.orama.network
|
||||||
|
# Backend: https://myapp-api.orama.network
|
||||||
|
```
|
||||||
|
|
||||||
|
**📖 Full Guide**: See [Deployment Guide](docs/DEPLOYMENT_GUIDE.md) for complete documentation, examples, and best practices.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build the core network binaries
|
# Build all binaries
|
||||||
make core-build
|
make build
|
||||||
|
|
||||||
# Run tests
|
|
||||||
make core-test
|
|
||||||
|
|
||||||
# Start website dev server
|
|
||||||
make website-dev
|
|
||||||
|
|
||||||
# Build vault
|
|
||||||
make vault-build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orama auth login # Authenticate with wallet
|
||||||
|
orama auth status # Check authentication
|
||||||
|
orama auth logout # Clear credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
### Application Deployments
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy applications
|
||||||
|
orama deploy static <path> --name myapp # React, Vue, static sites
|
||||||
|
orama deploy nextjs <path> --name myapp --ssr # Next.js with SSR (requires output: 'standalone')
|
||||||
|
orama deploy go <path> --name myapp # Go binaries (must have /health endpoint)
|
||||||
|
orama deploy nodejs <path> --name myapp # Node.js apps (must have /health endpoint)
|
||||||
|
|
||||||
|
# Manage deployments
|
||||||
|
orama deployments list # List all deployments
|
||||||
|
orama deployments get <name> # Get deployment details
|
||||||
|
orama deployments logs <name> --follow # View logs
|
||||||
|
orama deployments delete <name> # Delete deployment
|
||||||
|
orama deployments rollback <name> --version 1 # Rollback to version
|
||||||
|
```
|
||||||
|
|
||||||
|
### SQLite Databases
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orama db create <name> # Create database
|
||||||
|
orama db query <name> "SELECT * FROM t" # Execute SQL query
|
||||||
|
orama db list # List all databases
|
||||||
|
orama db backup <name> # Backup to IPFS
|
||||||
|
orama db backups <name> # List backups
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orama health # Cluster health check
|
||||||
|
orama peers # List connected peers
|
||||||
|
orama status # Network status
|
||||||
|
```
|
||||||
|
|
||||||
|
### RQLite Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orama query "SELECT * FROM users"
|
||||||
|
orama query "CREATE TABLE users (id INTEGER PRIMARY KEY)"
|
||||||
|
orama transaction --file ops.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pub/Sub
|
||||||
|
|
||||||
|
```bash
|
||||||
|
orama pubsub publish <topic> <message>
|
||||||
|
orama pubsub subscribe <topic> 30s
|
||||||
|
orama pubsub topics
|
||||||
|
```
|
||||||
|
|
||||||
|
## Serverless Functions (WASM)
|
||||||
|
|
||||||
|
Orama supports high-performance serverless function execution using WebAssembly (WASM). Functions are isolated, secure, and can interact with network services like the distributed cache.
|
||||||
|
|
||||||
|
### 1. Build Functions
|
||||||
|
|
||||||
|
Functions must be compiled to WASM. We recommend using [TinyGo](https://tinygo.org/).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build example functions to examples/functions/bin/
|
||||||
|
./examples/functions/build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Deployment
|
||||||
|
|
||||||
|
Deploy your compiled `.wasm` file to the network via the Gateway.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deploy a function
|
||||||
|
curl -X POST https://your-node.example.com/v1/functions \
|
||||||
|
-H "Authorization: Bearer <your_api_key>" \
|
||||||
|
-F "name=hello-world" \
|
||||||
|
-F "namespace=default" \
|
||||||
|
-F "wasm=@./examples/functions/bin/hello.wasm"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Invocation
|
||||||
|
|
||||||
|
Trigger your function with a JSON payload. The function receives the payload via `stdin` and returns its response via `stdout`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Invoke via HTTP
|
||||||
|
curl -X POST https://your-node.example.com/v1/functions/hello-world/invoke \
|
||||||
|
-H "Authorization: Bearer <your_api_key>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "Developer"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all functions in a namespace
|
||||||
|
curl https://your-node.example.com/v1/functions?namespace=default
|
||||||
|
|
||||||
|
# Delete a function
|
||||||
|
curl -X DELETE https://your-node.example.com/v1/functions/hello-world?namespace=default
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Ubuntu 22.04+ or Debian 12+
|
||||||
|
- `amd64` or `arm64` architecture
|
||||||
|
- 4GB RAM, 50GB SSD, 2 CPU cores
|
||||||
|
|
||||||
|
### Required Ports
|
||||||
|
|
||||||
|
**External (must be open in firewall):**
|
||||||
|
|
||||||
|
- **80** - HTTP (ACME/Let's Encrypt certificate challenges)
|
||||||
|
- **443** - HTTPS (Main gateway API endpoint)
|
||||||
|
- **4101** - IPFS Swarm (peer connections)
|
||||||
|
- **7001** - RQLite Raft (cluster consensus)
|
||||||
|
|
||||||
|
**Internal (bound to localhost, no firewall needed):**
|
||||||
|
|
||||||
|
- 4501 - IPFS API
|
||||||
|
- 5001 - RQLite HTTP API
|
||||||
|
- 6001 - Unified Gateway
|
||||||
|
- 8080 - IPFS Gateway
|
||||||
|
- 9050 - Anyone SOCKS5 proxy
|
||||||
|
- 9094 - IPFS Cluster API
|
||||||
|
- 3320/3322 - Olric Cache
|
||||||
|
|
||||||
|
**Anyone Relay Mode (optional, for earning rewards):**
|
||||||
|
|
||||||
|
- 9001 - Anyone ORPort (relay traffic, must be open externally)
|
||||||
|
|
||||||
|
### Anyone Network Integration
|
||||||
|
|
||||||
|
Orama Network integrates with the [Anyone Protocol](https://anyone.io) for anonymous routing. By default, nodes run as **clients** (consuming the network). Optionally, you can run as a **relay operator** to earn rewards.
|
||||||
|
|
||||||
|
**Client Mode (Default):**
|
||||||
|
- Routes traffic through Anyone network for anonymity
|
||||||
|
- SOCKS5 proxy on localhost:9050
|
||||||
|
- No rewards, just consumes network
|
||||||
|
|
||||||
|
**Relay Mode (Earn Rewards):**
|
||||||
|
- Provide bandwidth to the Anyone network
|
||||||
|
- Earn $ANYONE tokens as a relay operator
|
||||||
|
- Requires 100 $ANYONE tokens in your wallet
|
||||||
|
- Requires ORPort (9001) open to the internet
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install as relay operator (earn rewards)
|
||||||
|
sudo orama install --vps-ip <IP> --domain <domain> \
|
||||||
|
--anyone-relay \
|
||||||
|
--anyone-nickname "MyRelay" \
|
||||||
|
--anyone-contact "operator@email.com" \
|
||||||
|
--anyone-wallet "0x1234...abcd"
|
||||||
|
|
||||||
|
# With exit relay (legal implications apply)
|
||||||
|
sudo orama install --vps-ip <IP> --domain <domain> \
|
||||||
|
--anyone-relay \
|
||||||
|
--anyone-exit \
|
||||||
|
--anyone-nickname "MyExitRelay" \
|
||||||
|
--anyone-contact "operator@email.com" \
|
||||||
|
--anyone-wallet "0x1234...abcd"
|
||||||
|
|
||||||
|
# Migrate existing Anyone installation
|
||||||
|
sudo orama install --vps-ip <IP> --domain <domain> \
|
||||||
|
--anyone-relay \
|
||||||
|
--anyone-migrate \
|
||||||
|
--anyone-nickname "MyRelay" \
|
||||||
|
--anyone-contact "operator@email.com" \
|
||||||
|
--anyone-wallet "0x1234...abcd"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:** After installation, register your relay at [dashboard.anyone.io](https://dashboard.anyone.io) to start earning rewards.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
**macOS (Homebrew):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install DeBrosOfficial/tap/orama
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux (Debian/Ubuntu):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download and install the latest .deb package
|
||||||
|
curl -sL https://github.com/DeBrosOfficial/network/releases/latest/download/orama_$(curl -s https://api.github.com/repos/DeBrosOfficial/network/releases/latest | grep tag_name | cut -d '"' -f 4 | tr -d 'v')_linux_amd64.deb -o orama.deb
|
||||||
|
sudo dpkg -i orama.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
**From Source:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go install github.com/DeBrosOfficial/network/cmd/cli@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Setup (after installation):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo orama install --interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Status
|
||||||
|
orama status
|
||||||
|
|
||||||
|
# Control services
|
||||||
|
sudo orama start
|
||||||
|
sudo orama stop
|
||||||
|
sudo orama restart
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
orama logs node --follow
|
||||||
|
orama logs gateway --follow
|
||||||
|
orama logs ipfs --follow
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Upgrade to latest version
|
||||||
|
sudo orama upgrade --interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration lives in `~/.orama/`:
|
||||||
|
|
||||||
|
- `configs/node.yaml` - Node configuration
|
||||||
|
- `configs/gateway.yaml` - Gateway configuration
|
||||||
|
- `configs/olric.yaml` - Cache configuration
|
||||||
|
- `secrets/` - Keys and certificates
|
||||||
|
- `data/` - Service data directories
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Services Not Starting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check status
|
||||||
|
systemctl status orama-node
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
journalctl -u orama-node -f
|
||||||
|
|
||||||
|
# Check log files
|
||||||
|
tail -f /opt/orama/.orama/logs/node.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Conflicts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check what's using specific ports
|
||||||
|
sudo lsof -i :443 # HTTPS Gateway
|
||||||
|
sudo lsof -i :7001 # TCP/SNI Gateway
|
||||||
|
sudo lsof -i :6001 # Internal Gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
### RQLite Cluster Issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Connect to RQLite CLI
|
||||||
|
rqlite -H localhost -p 5001
|
||||||
|
|
||||||
|
# Check cluster status
|
||||||
|
.nodes
|
||||||
|
.status
|
||||||
|
.ready
|
||||||
|
|
||||||
|
# Check consistency level
|
||||||
|
.consistency
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production reset (⚠️ DESTROYS DATA)
|
||||||
|
sudo orama uninstall
|
||||||
|
sudo rm -rf /opt/orama/.orama
|
||||||
|
sudo orama install
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTTP Gateway API
|
||||||
|
|
||||||
|
### Main Gateway Endpoints
|
||||||
|
|
||||||
|
- `GET /health` - Health status
|
||||||
|
- `GET /v1/status` - Full status
|
||||||
|
- `GET /v1/version` - Version info
|
||||||
|
- `POST /v1/rqlite/exec` - Execute SQL
|
||||||
|
- `POST /v1/rqlite/query` - Query database
|
||||||
|
- `GET /v1/rqlite/schema` - Get schema
|
||||||
|
- `POST /v1/pubsub/publish` - Publish message
|
||||||
|
- `GET /v1/pubsub/topics` - List topics
|
||||||
|
- `GET /v1/pubsub/ws?topic=<name>` - WebSocket subscribe
|
||||||
|
- `POST /v1/functions` - Deploy function (multipart/form-data)
|
||||||
|
- `POST /v1/functions/{name}/invoke` - Invoke function
|
||||||
|
- `GET /v1/functions` - List functions
|
||||||
|
- `DELETE /v1/functions/{name}` - Delete function
|
||||||
|
- `GET /v1/functions/{name}/logs` - Get function logs
|
||||||
|
|
||||||
|
See `openapi/gateway.yaml` for complete API specification.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| Document | Description |
|
- **[Deployment Guide](docs/DEPLOYMENT_GUIDE.md)** - Deploy React, Next.js, Go apps and manage databases
|
||||||
|----------|-------------|
|
- **[Architecture Guide](docs/ARCHITECTURE.md)** - System architecture and design patterns
|
||||||
| [Architecture](core/docs/ARCHITECTURE.md) | System architecture and design patterns |
|
- **[Client SDK](docs/CLIENT_SDK.md)** - Go SDK documentation and examples
|
||||||
| [Deployment Guide](core/docs/DEPLOYMENT_GUIDE.md) | Deploy apps, databases, and domains |
|
- **[Gateway API](docs/GATEWAY_API.md)** - Complete HTTP API reference
|
||||||
| [Dev & Deploy](core/docs/DEV_DEPLOY.md) | Building, deploying to VPS, rolling upgrades |
|
- **[Security Deployment](docs/SECURITY_DEPLOYMENT_GUIDE.md)** - Production security hardening
|
||||||
| [Security](core/docs/SECURITY.md) | Security hardening and threat model |
|
- **[Testing Plan](docs/TESTING_PLAN.md)** - Comprehensive testing strategy and implementation
|
||||||
| [Monitoring](core/docs/MONITORING.md) | Cluster health monitoring |
|
|
||||||
| [Client SDK](core/docs/CLIENT_SDK.md) | Go SDK documentation |
|
## Resources
|
||||||
| [Serverless](core/docs/SERVERLESS.md) | WASM serverless functions |
|
|
||||||
| [Common Problems](core/docs/COMMON_PROBLEMS.md) | Troubleshooting known issues |
|
- [RQLite Documentation](https://rqlite.io/docs/)
|
||||||
|
- [IPFS Documentation](https://docs.ipfs.tech/)
|
||||||
|
- [LibP2P Documentation](https://docs.libp2p.io/)
|
||||||
|
- [WebAssembly](https://webassembly.org/)
|
||||||
|
- [GitHub Repository](https://github.com/DeBrosOfficial/network)
|
||||||
|
- [Issue Tracker](https://github.com/DeBrosOfficial/network/issues)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
network/
|
||||||
|
├── cmd/ # Binary entry points
|
||||||
|
│ ├── cli/ # CLI tool
|
||||||
|
│ ├── gateway/ # HTTP Gateway
|
||||||
|
│ ├── node/ # P2P Node
|
||||||
|
├── pkg/ # Core packages
|
||||||
|
│ ├── gateway/ # Gateway implementation
|
||||||
|
│ │ └── handlers/ # HTTP handlers by domain
|
||||||
|
│ ├── client/ # Go SDK
|
||||||
|
│ ├── serverless/ # WASM engine
|
||||||
|
│ ├── rqlite/ # Database ORM
|
||||||
|
│ ├── contracts/ # Interface definitions
|
||||||
|
│ ├── httputil/ # HTTP utilities
|
||||||
|
│ └── errors/ # Error handling
|
||||||
|
├── docs/ # Documentation
|
||||||
|
├── e2e/ # End-to-end tests
|
||||||
|
└── examples/ # Example code
|
||||||
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, development, and PR guidelines.
|
Contributions are welcome! This project follows:
|
||||||
|
- **SOLID Principles** - Single responsibility, open/closed, etc.
|
||||||
|
- **DRY Principle** - Don't repeat yourself
|
||||||
|
- **Clean Architecture** - Clear separation of concerns
|
||||||
|
- **Test Coverage** - Unit and E2E tests required
|
||||||
|
|
||||||
## License
|
See our architecture docs for design patterns and guidelines.
|
||||||
|
|
||||||
[AGPL-3.0](LICENSE)
|
|
||||||
|
|||||||
@ -9,16 +9,12 @@ import (
|
|||||||
// Command groups
|
// Command groups
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/cmd/app"
|
"github.com/DeBrosOfficial/network/pkg/cli/cmd/app"
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/cmd/authcmd"
|
"github.com/DeBrosOfficial/network/pkg/cli/cmd/authcmd"
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/cmd/buildcmd"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/cmd/dbcmd"
|
"github.com/DeBrosOfficial/network/pkg/cli/cmd/dbcmd"
|
||||||
deploycmd "github.com/DeBrosOfficial/network/pkg/cli/cmd/deploy"
|
deploycmd "github.com/DeBrosOfficial/network/pkg/cli/cmd/deploy"
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/cmd/envcmd"
|
"github.com/DeBrosOfficial/network/pkg/cli/cmd/envcmd"
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/cmd/functioncmd"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/cmd/inspectcmd"
|
"github.com/DeBrosOfficial/network/pkg/cli/cmd/inspectcmd"
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/cmd/monitorcmd"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/cmd/namespacecmd"
|
"github.com/DeBrosOfficial/network/pkg/cli/cmd/namespacecmd"
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/cmd/node"
|
"github.com/DeBrosOfficial/network/pkg/cli/cmd/node"
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/cmd/sandboxcmd"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// version metadata populated via -ldflags at build time
|
// version metadata populated via -ldflags at build time
|
||||||
@ -79,18 +75,6 @@ and interacting with the Orama distributed network.`,
|
|||||||
// Inspect command
|
// Inspect command
|
||||||
rootCmd.AddCommand(inspectcmd.Cmd)
|
rootCmd.AddCommand(inspectcmd.Cmd)
|
||||||
|
|
||||||
// Monitor command
|
|
||||||
rootCmd.AddCommand(monitorcmd.Cmd)
|
|
||||||
|
|
||||||
// Serverless function commands
|
|
||||||
rootCmd.AddCommand(functioncmd.Cmd)
|
|
||||||
|
|
||||||
// Build command (cross-compile binary archive)
|
|
||||||
rootCmd.AddCommand(buildcmd.Cmd)
|
|
||||||
|
|
||||||
// Sandbox command (ephemeral Hetzner Cloud clusters)
|
|
||||||
rootCmd.AddCommand(sandboxcmd.Cmd)
|
|
||||||
|
|
||||||
return rootCmd
|
return rootCmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,13 +69,6 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load YAML
|
// Load YAML
|
||||||
type yamlWebRTCCfg struct {
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
SFUPort int `yaml:"sfu_port"`
|
|
||||||
TURNDomain string `yaml:"turn_domain"`
|
|
||||||
TURNSecret string `yaml:"turn_secret"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type yamlCfg struct {
|
type yamlCfg struct {
|
||||||
ListenAddr string `yaml:"listen_addr"`
|
ListenAddr string `yaml:"listen_addr"`
|
||||||
ClientNamespace string `yaml:"client_namespace"`
|
ClientNamespace string `yaml:"client_namespace"`
|
||||||
@ -91,7 +84,6 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
|
|||||||
IPFSAPIURL string `yaml:"ipfs_api_url"`
|
IPFSAPIURL string `yaml:"ipfs_api_url"`
|
||||||
IPFSTimeout string `yaml:"ipfs_timeout"`
|
IPFSTimeout string `yaml:"ipfs_timeout"`
|
||||||
IPFSReplicationFactor int `yaml:"ipfs_replication_factor"`
|
IPFSReplicationFactor int `yaml:"ipfs_replication_factor"`
|
||||||
WebRTC yamlWebRTCCfg `yaml:"webrtc"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(configPath)
|
data, err := os.ReadFile(configPath)
|
||||||
@ -200,18 +192,6 @@ func parseGatewayConfig(logger *logging.ColoredLogger) *gateway.Config {
|
|||||||
cfg.IPFSReplicationFactor = y.IPFSReplicationFactor
|
cfg.IPFSReplicationFactor = y.IPFSReplicationFactor
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebRTC configuration
|
|
||||||
cfg.WebRTCEnabled = y.WebRTC.Enabled
|
|
||||||
if y.WebRTC.SFUPort > 0 {
|
|
||||||
cfg.SFUPort = y.WebRTC.SFUPort
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(y.WebRTC.TURNDomain); v != "" {
|
|
||||||
cfg.TURNDomain = v
|
|
||||||
}
|
|
||||||
if v := strings.TrimSpace(y.WebRTC.TURNSecret); v != "" {
|
|
||||||
cfg.TURNSecret = v
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate configuration
|
// Validate configuration
|
||||||
if errs := cfg.ValidateConfig(); len(errs) > 0 {
|
if errs := cfg.ValidateConfig(); len(errs) > 0 {
|
||||||
fmt.Fprintf(os.Stderr, "\nGateway configuration errors (%d):\n", len(errs))
|
fmt.Fprintf(os.Stderr, "\nGateway configuration errors (%d):\n", len(errs))
|
||||||
@ -1,8 +0,0 @@
|
|||||||
# OpenRouter API Key for changelog generation
|
|
||||||
# Get your API key from https://openrouter.ai/keys
|
|
||||||
OPENROUTER_API_KEY=your-api-key-here
|
|
||||||
|
|
||||||
# ZeroSSL API Key for TLS certificates (alternative to Let's Encrypt)
|
|
||||||
# Get your free API key from https://app.zerossl.com/developer
|
|
||||||
# If not set, Caddy will use Let's Encrypt as the default CA
|
|
||||||
ZEROSSL_API_KEY=
|
|
||||||
181
core/Makefile
181
core/Makefile
@ -1,181 +0,0 @@
|
|||||||
TEST?=./...
|
|
||||||
|
|
||||||
.PHONY: test
|
|
||||||
test:
|
|
||||||
@echo Running tests...
|
|
||||||
go test -v $(TEST)
|
|
||||||
|
|
||||||
# Gateway-focused E2E tests assume gateway and nodes are already running
|
|
||||||
# Auto-discovers configuration from ~/.orama and queries database for API key
|
|
||||||
# No environment variables required
|
|
||||||
.PHONY: test-e2e test-e2e-deployments test-e2e-fullstack test-e2e-https test-e2e-quick test-e2e-prod test-e2e-shared test-e2e-cluster test-e2e-integration test-e2e-production
|
|
||||||
|
|
||||||
# Production E2E tests - includes production-only tests
|
|
||||||
test-e2e-prod:
|
|
||||||
@if [ -z "$$ORAMA_GATEWAY_URL" ]; then \
|
|
||||||
echo "❌ ORAMA_GATEWAY_URL not set"; \
|
|
||||||
echo "Usage: ORAMA_GATEWAY_URL=https://dbrs.space make test-e2e-prod"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
@echo "Running E2E tests (including production-only) against $$ORAMA_GATEWAY_URL..."
|
|
||||||
go test -v -tags "e2e production" -timeout 30m ./e2e/...
|
|
||||||
|
|
||||||
# Generic e2e target
|
|
||||||
test-e2e:
|
|
||||||
@echo "Running comprehensive E2E tests..."
|
|
||||||
@echo "Auto-discovering configuration from ~/.orama..."
|
|
||||||
go test -v -tags e2e -timeout 30m ./e2e/...
|
|
||||||
|
|
||||||
test-e2e-deployments:
|
|
||||||
@echo "Running deployment E2E tests..."
|
|
||||||
go test -v -tags e2e -timeout 15m ./e2e/deployments/...
|
|
||||||
|
|
||||||
test-e2e-fullstack:
|
|
||||||
@echo "Running fullstack E2E tests..."
|
|
||||||
go test -v -tags e2e -timeout 20m -run "TestFullStack" ./e2e/...
|
|
||||||
|
|
||||||
test-e2e-https:
|
|
||||||
@echo "Running HTTPS/external access E2E tests..."
|
|
||||||
go test -v -tags e2e -timeout 10m -run "TestHTTPS" ./e2e/...
|
|
||||||
|
|
||||||
test-e2e-shared:
|
|
||||||
@echo "Running shared E2E tests..."
|
|
||||||
go test -v -tags e2e -timeout 10m ./e2e/shared/...
|
|
||||||
|
|
||||||
test-e2e-cluster:
|
|
||||||
@echo "Running cluster E2E tests..."
|
|
||||||
go test -v -tags e2e -timeout 15m ./e2e/cluster/...
|
|
||||||
|
|
||||||
test-e2e-integration:
|
|
||||||
@echo "Running integration E2E tests..."
|
|
||||||
go test -v -tags e2e -timeout 20m ./e2e/integration/...
|
|
||||||
|
|
||||||
test-e2e-production:
|
|
||||||
@echo "Running production-only E2E tests..."
|
|
||||||
go test -v -tags "e2e production" -timeout 15m ./e2e/production/...
|
|
||||||
|
|
||||||
test-e2e-quick:
|
|
||||||
@echo "Running quick E2E smoke tests..."
|
|
||||||
go test -v -tags e2e -timeout 5m -run "TestStatic|TestHealth" ./e2e/...
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
VERSION := 0.120.0
|
|
||||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
|
||||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
||||||
LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)' -X 'main.date=$(DATE)'
|
|
||||||
LDFLAGS_LINUX := -s -w $(LDFLAGS)
|
|
||||||
|
|
||||||
# Build targets
|
|
||||||
build: deps
|
|
||||||
@echo "Building network executables (version=$(VERSION))..."
|
|
||||||
@mkdir -p bin
|
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/identity ./cmd/identity
|
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/orama-node ./cmd/node
|
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/orama ./cmd/cli/
|
|
||||||
# Inject gateway build metadata via pkg path variables
|
|
||||||
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
|
|
||||||
@echo "Build complete! Run ./bin/orama version"
|
|
||||||
|
|
||||||
# Cross-compile CLI for Linux (only binary needed locally; VPS builds everything else from source)
|
|
||||||
build-linux: deps
|
|
||||||
@echo "Cross-compiling CLI for linux/amd64 (version=$(VERSION))..."
|
|
||||||
@mkdir -p bin-linux
|
|
||||||
GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS_LINUX)" -trimpath -o bin-linux/orama ./cmd/cli/
|
|
||||||
@echo "✓ CLI built at bin-linux/orama"
|
|
||||||
@echo ""
|
|
||||||
@echo "Prefer 'make build-archive' for full pre-built binary archive."
|
|
||||||
|
|
||||||
# Build pre-compiled binary archive for deployment (all binaries + deps)
|
|
||||||
build-archive: deps
|
|
||||||
@echo "Building binary archive (version=$(VERSION))..."
|
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/orama ./cmd/cli/
|
|
||||||
./bin/orama build --output /tmp/orama-$(VERSION)-linux-amd64.tar.gz
|
|
||||||
|
|
||||||
# Install git hooks
|
|
||||||
install-hooks:
|
|
||||||
@echo "Installing git hooks..."
|
|
||||||
@bash scripts/install-hooks.sh
|
|
||||||
|
|
||||||
# Install orama CLI to ~/.local/bin and configure PATH
|
|
||||||
install: build
|
|
||||||
@bash scripts/install.sh
|
|
||||||
|
|
||||||
# Clean build artifacts
|
|
||||||
clean:
|
|
||||||
@echo "Cleaning build artifacts..."
|
|
||||||
rm -rf bin/
|
|
||||||
rm -rf data/
|
|
||||||
@echo "Clean complete!"
|
|
||||||
|
|
||||||
# Push binary archive to devnet nodes (fanout distribution)
|
|
||||||
push-devnet:
|
|
||||||
./bin/orama node push --env devnet
|
|
||||||
|
|
||||||
# Push binary archive to testnet nodes (fanout distribution)
|
|
||||||
push-testnet:
|
|
||||||
./bin/orama node push --env testnet
|
|
||||||
|
|
||||||
# Full rollout to devnet (build + push + rolling upgrade)
|
|
||||||
rollout-devnet:
|
|
||||||
./bin/orama node rollout --env devnet --yes
|
|
||||||
|
|
||||||
# Full rollout to testnet (build + push + rolling upgrade)
|
|
||||||
rollout-testnet:
|
|
||||||
./bin/orama node rollout --env testnet --yes
|
|
||||||
|
|
||||||
# Interactive release workflow (tag + push)
|
|
||||||
release:
|
|
||||||
@bash scripts/release.sh
|
|
||||||
|
|
||||||
# Check health of all nodes in an environment
|
|
||||||
# Usage: make health ENV=devnet
|
|
||||||
health:
|
|
||||||
@if [ -z "$(ENV)" ]; then \
|
|
||||||
echo "Usage: make health ENV=devnet|testnet"; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
./bin/orama monitor report --env $(ENV)
|
|
||||||
|
|
||||||
# Help
|
|
||||||
help:
|
|
||||||
@echo "Available targets:"
|
|
||||||
@echo " build - Build all executables"
|
|
||||||
@echo " install - Build and install 'orama' CLI to ~/.local/bin"
|
|
||||||
@echo " clean - Clean build artifacts"
|
|
||||||
@echo " test - Run unit tests"
|
|
||||||
@echo ""
|
|
||||||
@echo "E2E Testing:"
|
|
||||||
@echo " make test-e2e-prod - Run all E2E tests incl. production-only (needs ORAMA_GATEWAY_URL)"
|
|
||||||
@echo " make test-e2e-shared - Run shared E2E tests (cache, storage, pubsub, auth)"
|
|
||||||
@echo " make test-e2e-cluster - Run cluster E2E tests (libp2p, olric, rqlite, namespace)"
|
|
||||||
@echo " make test-e2e-integration - Run integration E2E tests (fullstack, persistence, concurrency)"
|
|
||||||
@echo " make test-e2e-deployments - Run deployment E2E tests"
|
|
||||||
@echo " make test-e2e-production - Run production-only E2E tests (DNS, HTTPS, cross-node)"
|
|
||||||
@echo " make test-e2e-quick - Quick smoke tests (static deploys, health checks)"
|
|
||||||
@echo " make test-e2e - Generic E2E tests (auto-discovers config)"
|
|
||||||
@echo ""
|
|
||||||
@echo " Example:"
|
|
||||||
@echo " ORAMA_GATEWAY_URL=https://orama-devnet.network make test-e2e-prod"
|
|
||||||
@echo ""
|
|
||||||
@echo "Deployment:"
|
|
||||||
@echo " make build-archive - Build pre-compiled binary archive for deployment"
|
|
||||||
@echo " make push-devnet - Push binary archive to devnet nodes"
|
|
||||||
@echo " make push-testnet - Push binary archive to testnet nodes"
|
|
||||||
@echo " make rollout-devnet - Full rollout: build + push + rolling upgrade (devnet)"
|
|
||||||
@echo " make rollout-testnet - Full rollout: build + push + rolling upgrade (testnet)"
|
|
||||||
@echo " make health ENV=devnet - Check health of all nodes in an environment"
|
|
||||||
@echo " make release - Interactive release workflow (tag + push)"
|
|
||||||
@echo ""
|
|
||||||
@echo "Maintenance:"
|
|
||||||
@echo " deps - Download dependencies"
|
|
||||||
@echo " tidy - Tidy dependencies"
|
|
||||||
@echo " fmt - Format code"
|
|
||||||
@echo " vet - Vet code"
|
|
||||||
@echo " lint - Lint code (fmt + vet)"
|
|
||||||
@echo " help - Show this help"
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/config"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/logging"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/sfu"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
// newSFUServer creates a new SFU server from config and logger.
|
|
||||||
// Wrapper to keep main.go clean and avoid importing sfu in main.
|
|
||||||
func newSFUServer(cfg *sfu.Config, logger *zap.Logger) (*sfu.Server, error) {
|
|
||||||
return sfu.NewServer(cfg, logger)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSFUConfig(logger *logging.ColoredLogger) *sfu.Config {
|
|
||||||
configFlag := flag.String("config", "", "Config file path (absolute path 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.ComponentSFU, "Failed to determine config path", zap.Error(err))
|
|
||||||
fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
configPath, err = config.DefaultPath("sfu.yaml")
|
|
||||||
if err != nil {
|
|
||||||
logger.ComponentError(logging.ComponentSFU, "Failed to determine config path", zap.Error(err))
|
|
||||||
fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type yamlTURNServer struct {
|
|
||||||
Host string `yaml:"host"`
|
|
||||||
Port int `yaml:"port"`
|
|
||||||
Secure bool `yaml:"secure"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type yamlCfg struct {
|
|
||||||
ListenAddr string `yaml:"listen_addr"`
|
|
||||||
Namespace string `yaml:"namespace"`
|
|
||||||
MediaPortStart int `yaml:"media_port_start"`
|
|
||||||
MediaPortEnd int `yaml:"media_port_end"`
|
|
||||||
TURNServers []yamlTURNServer `yaml:"turn_servers"`
|
|
||||||
TURNSecret string `yaml:"turn_secret"`
|
|
||||||
TURNCredentialTTL int `yaml:"turn_credential_ttl"`
|
|
||||||
RQLiteDSN string `yaml:"rqlite_dsn"`
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
logger.ComponentError(logging.ComponentSFU, "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 yamlCfg
|
|
||||||
if err := config.DecodeStrict(strings.NewReader(string(data)), &y); err != nil {
|
|
||||||
logger.ComponentError(logging.ComponentSFU, "Failed to parse SFU config", zap.Error(err))
|
|
||||||
fmt.Fprintf(os.Stderr, "Configuration parse error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
var turnServers []sfu.TURNServerConfig
|
|
||||||
for _, ts := range y.TURNServers {
|
|
||||||
turnServers = append(turnServers, sfu.TURNServerConfig{
|
|
||||||
Host: ts.Host,
|
|
||||||
Port: ts.Port,
|
|
||||||
Secure: ts.Secure,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &sfu.Config{
|
|
||||||
ListenAddr: y.ListenAddr,
|
|
||||||
Namespace: y.Namespace,
|
|
||||||
MediaPortStart: y.MediaPortStart,
|
|
||||||
MediaPortEnd: y.MediaPortEnd,
|
|
||||||
TURNServers: turnServers,
|
|
||||||
TURNSecret: y.TURNSecret,
|
|
||||||
TURNCredentialTTL: y.TURNCredentialTTL,
|
|
||||||
RQLiteDSN: y.RQLiteDSN,
|
|
||||||
}
|
|
||||||
|
|
||||||
if errs := cfg.Validate(); len(errs) > 0 {
|
|
||||||
fmt.Fprintf(os.Stderr, "\nSFU 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.ComponentSFU, "Loaded SFU configuration",
|
|
||||||
zap.String("path", configPath),
|
|
||||||
zap.String("listen_addr", cfg.ListenAddr),
|
|
||||||
zap.String("namespace", cfg.Namespace),
|
|
||||||
zap.Int("media_ports", cfg.MediaPortEnd-cfg.MediaPortStart),
|
|
||||||
zap.Int("turn_servers", len(cfg.TURNServers)),
|
|
||||||
)
|
|
||||||
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/logging"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
version = "dev"
|
|
||||||
commit = "unknown"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
logger, err := logging.NewColoredLogger(logging.ComponentSFU, true)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.ComponentInfo(logging.ComponentSFU, "Starting SFU server",
|
|
||||||
zap.String("version", version),
|
|
||||||
zap.String("commit", commit))
|
|
||||||
|
|
||||||
cfg := parseSFUConfig(logger)
|
|
||||||
|
|
||||||
server, err := newSFUServer(cfg, logger.Logger)
|
|
||||||
if err != nil {
|
|
||||||
logger.ComponentError(logging.ComponentSFU, "Failed to create SFU server", zap.Error(err))
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start HTTP server in background
|
|
||||||
go func() {
|
|
||||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
logger.ComponentError(logging.ComponentSFU, "SFU server error", zap.Error(err))
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait for termination signal
|
|
||||||
quit := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
|
||||||
sig := <-quit
|
|
||||||
|
|
||||||
logger.ComponentInfo(logging.ComponentSFU, "Shutdown signal received", zap.String("signal", sig.String()))
|
|
||||||
|
|
||||||
// Graceful drain: notify peers and wait
|
|
||||||
server.Drain(30 * time.Second)
|
|
||||||
|
|
||||||
if err := server.Close(); err != nil {
|
|
||||||
logger.ComponentError(logging.ComponentSFU, "Error during shutdown", zap.Error(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.ComponentInfo(logging.ComponentSFU, "SFU server shutdown complete")
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/config"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/logging"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/turn"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
func parseTURNConfig(logger *logging.ColoredLogger) *turn.Config {
|
|
||||||
configFlag := flag.String("config", "", "Config file path (absolute path 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.ComponentTURN, "Failed to determine config path", zap.Error(err))
|
|
||||||
fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
configPath, err = config.DefaultPath("turn.yaml")
|
|
||||||
if err != nil {
|
|
||||||
logger.ComponentError(logging.ComponentTURN, "Failed to determine config path", zap.Error(err))
|
|
||||||
fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type yamlCfg struct {
|
|
||||||
ListenAddr string `yaml:"listen_addr"`
|
|
||||||
TURNSListenAddr string `yaml:"turns_listen_addr"`
|
|
||||||
PublicIP string `yaml:"public_ip"`
|
|
||||||
Realm string `yaml:"realm"`
|
|
||||||
AuthSecret string `yaml:"auth_secret"`
|
|
||||||
RelayPortStart int `yaml:"relay_port_start"`
|
|
||||||
RelayPortEnd int `yaml:"relay_port_end"`
|
|
||||||
Namespace string `yaml:"namespace"`
|
|
||||||
TLSCertPath string `yaml:"tls_cert_path"`
|
|
||||||
TLSKeyPath string `yaml:"tls_key_path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
|
||||||
logger.ComponentError(logging.ComponentTURN, "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 yamlCfg
|
|
||||||
if err := config.DecodeStrict(strings.NewReader(string(data)), &y); err != nil {
|
|
||||||
logger.ComponentError(logging.ComponentTURN, "Failed to parse TURN config", zap.Error(err))
|
|
||||||
fmt.Fprintf(os.Stderr, "Configuration parse error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &turn.Config{
|
|
||||||
ListenAddr: y.ListenAddr,
|
|
||||||
TURNSListenAddr: y.TURNSListenAddr,
|
|
||||||
PublicIP: y.PublicIP,
|
|
||||||
Realm: y.Realm,
|
|
||||||
AuthSecret: y.AuthSecret,
|
|
||||||
RelayPortStart: y.RelayPortStart,
|
|
||||||
RelayPortEnd: y.RelayPortEnd,
|
|
||||||
Namespace: y.Namespace,
|
|
||||||
TLSCertPath: y.TLSCertPath,
|
|
||||||
TLSKeyPath: y.TLSKeyPath,
|
|
||||||
}
|
|
||||||
|
|
||||||
if errs := cfg.Validate(); len(errs) > 0 {
|
|
||||||
fmt.Fprintf(os.Stderr, "\nTURN 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.ComponentTURN, "Loaded TURN configuration",
|
|
||||||
zap.String("path", configPath),
|
|
||||||
zap.String("listen_addr", cfg.ListenAddr),
|
|
||||||
zap.String("namespace", cfg.Namespace),
|
|
||||||
zap.String("realm", cfg.Realm),
|
|
||||||
)
|
|
||||||
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/logging"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/turn"
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
version = "dev"
|
|
||||||
commit = "unknown"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
logger, err := logging.NewColoredLogger(logging.ComponentTURN, true)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.ComponentInfo(logging.ComponentTURN, "Starting TURN server",
|
|
||||||
zap.String("version", version),
|
|
||||||
zap.String("commit", commit))
|
|
||||||
|
|
||||||
cfg := parseTURNConfig(logger)
|
|
||||||
|
|
||||||
server, err := turn.NewServer(cfg, logger.Logger)
|
|
||||||
if err != nil {
|
|
||||||
logger.ComponentError(logging.ComponentTURN, "Failed to start TURN server", zap.Error(err))
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for termination signal
|
|
||||||
quit := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
|
|
||||||
sig := <-quit
|
|
||||||
|
|
||||||
logger.ComponentInfo(logging.ComponentTURN, "Shutdown signal received", zap.String("signal", sig.String()))
|
|
||||||
|
|
||||||
if err := server.Close(); err != nil {
|
|
||||||
logger.ComponentError(logging.ComponentTURN, "Error during shutdown", zap.Error(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.ComponentInfo(logging.ComponentTURN, "TURN server shutdown complete")
|
|
||||||
}
|
|
||||||
@ -1,278 +0,0 @@
|
|||||||
# Monitoring
|
|
||||||
|
|
||||||
Real-time cluster health monitoring via SSH. The system has two parts:
|
|
||||||
|
|
||||||
1. **`orama node report`** — Runs on each VPS node, collects all local health data, outputs JSON
|
|
||||||
2. **`orama monitor`** — Runs on your local machine, SSHes into nodes, aggregates results, displays via TUI or tables
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Developer Machine VPS Nodes (via SSH)
|
|
||||||
┌──────────────────┐ ┌────────────────────┐
|
|
||||||
│ orama monitor │ ──SSH──────────>│ orama node report │
|
|
||||||
│ (TUI / tables) │ <──JSON─────── │ (local collector) │
|
|
||||||
│ │ └────────────────────┘
|
|
||||||
│ CollectOnce() │ ──SSH──────────>│ orama node report │
|
|
||||||
│ DeriveAlerts() │ <──JSON─────── │ (local collector) │
|
|
||||||
│ Render() │ └────────────────────┘
|
|
||||||
└──────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Each node runs `orama node report --json` locally (no SSH to other nodes), collecting data via `os/exec` and `net/http` to localhost services. The monitor SSHes into all nodes in parallel, collects reports, then runs cross-node analysis to detect cluster-wide issues.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Interactive TUI (auto-refreshes every 30s)
|
|
||||||
orama monitor --env testnet
|
|
||||||
|
|
||||||
# Cluster overview table
|
|
||||||
orama monitor cluster --env testnet
|
|
||||||
|
|
||||||
# Alerts only
|
|
||||||
orama monitor alerts --env testnet
|
|
||||||
|
|
||||||
# Full JSON report (pipe to jq or feed to LLM)
|
|
||||||
orama monitor report --env testnet
|
|
||||||
```
|
|
||||||
|
|
||||||
## `orama monitor` — Local Orchestrator
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
```
|
|
||||||
orama monitor [subcommand] --env <environment> [flags]
|
|
||||||
```
|
|
||||||
|
|
||||||
Without a subcommand, launches the interactive TUI.
|
|
||||||
|
|
||||||
### Global Flags
|
|
||||||
|
|
||||||
| Flag | Default | Description |
|
|
||||||
|------|---------|-------------|
|
|
||||||
| `--env` | *(required)* | Environment: `devnet`, `testnet`, `mainnet` |
|
|
||||||
| `--json` | `false` | Machine-readable JSON output (for one-shot subcommands) |
|
|
||||||
| `--node` | | Filter to a specific node host/IP |
|
|
||||||
| `--config` | `scripts/remote-nodes.conf` | Path to node configuration file |
|
|
||||||
|
|
||||||
### Subcommands
|
|
||||||
|
|
||||||
| Subcommand | Description |
|
|
||||||
|------------|-------------|
|
|
||||||
| `live` | Interactive TUI monitor (default when no subcommand) |
|
|
||||||
| `cluster` | Cluster overview: all nodes, roles, RQLite state, WG peers |
|
|
||||||
| `node` | Per-node health details (system, services, WG, DNS) |
|
|
||||||
| `service` | Service status matrix across all nodes |
|
|
||||||
| `mesh` | WireGuard mesh connectivity and peer details |
|
|
||||||
| `dns` | DNS health: CoreDNS, Caddy, TLS cert expiry, resolution |
|
|
||||||
| `namespaces` | Namespace health across nodes |
|
|
||||||
| `alerts` | Active alerts and warnings sorted by severity |
|
|
||||||
| `report` | Full JSON dump optimized for LLM consumption |
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Cluster overview
|
|
||||||
orama monitor cluster --env testnet
|
|
||||||
|
|
||||||
# Cluster overview as JSON
|
|
||||||
orama monitor cluster --env testnet --json
|
|
||||||
|
|
||||||
# Alerts for all nodes
|
|
||||||
orama monitor alerts --env testnet
|
|
||||||
|
|
||||||
# Single-node deep dive
|
|
||||||
orama monitor node --env testnet --node 51.195.109.238
|
|
||||||
|
|
||||||
# Services for one node
|
|
||||||
orama monitor service --env testnet --node 51.195.109.238
|
|
||||||
|
|
||||||
# WireGuard mesh details
|
|
||||||
orama monitor mesh --env testnet
|
|
||||||
|
|
||||||
# DNS health
|
|
||||||
orama monitor dns --env testnet
|
|
||||||
|
|
||||||
# Namespace health
|
|
||||||
orama monitor namespaces --env testnet
|
|
||||||
|
|
||||||
# Full report for LLM analysis
|
|
||||||
orama monitor report --env testnet | jq .
|
|
||||||
|
|
||||||
# Single-node report
|
|
||||||
orama monitor report --env testnet --node 51.195.109.238
|
|
||||||
|
|
||||||
# Custom config file
|
|
||||||
orama monitor cluster --config /path/to/nodes.conf --env devnet
|
|
||||||
```
|
|
||||||
|
|
||||||
### Interactive TUI
|
|
||||||
|
|
||||||
The `live` subcommand (default) launches a full-screen terminal UI:
|
|
||||||
|
|
||||||
**Tabs:** Overview | Nodes | Services | WG Mesh | DNS | Namespaces | Alerts
|
|
||||||
|
|
||||||
**Key Bindings:**
|
|
||||||
|
|
||||||
| Key | Action |
|
|
||||||
|-----|--------|
|
|
||||||
| `Tab` / `Shift+Tab` | Switch tabs |
|
|
||||||
| `j` / `k` or `↑` / `↓` | Scroll content |
|
|
||||||
| `r` | Force refresh |
|
|
||||||
| `q` / `Ctrl+C` | Quit |
|
|
||||||
|
|
||||||
The TUI auto-refreshes every 30 seconds. A spinner shows during data collection. Colors indicate health: green = healthy, red = critical, yellow = warning.
|
|
||||||
|
|
||||||
### LLM Report Format
|
|
||||||
|
|
||||||
`orama monitor report` outputs structured JSON designed for AI consumption:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"meta": {
|
|
||||||
"environment": "testnet",
|
|
||||||
"collected_at": "2026-02-16T12:00:00Z",
|
|
||||||
"duration_seconds": 3.2,
|
|
||||||
"node_count": 3,
|
|
||||||
"healthy_count": 3
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"rqlite_leader": "10.0.0.1",
|
|
||||||
"rqlite_voters": "3/3",
|
|
||||||
"rqlite_raft_term": 42,
|
|
||||||
"wg_mesh_status": "all connected",
|
|
||||||
"service_health": "all nominal",
|
|
||||||
"critical_alerts": 0,
|
|
||||||
"warning_alerts": 1,
|
|
||||||
"info_alerts": 0
|
|
||||||
},
|
|
||||||
"alerts": [...],
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"host": "51.195.109.238",
|
|
||||||
"status": "healthy",
|
|
||||||
"collection_ms": 526,
|
|
||||||
"report": { ... }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## `orama node report` — VPS-Side Collector
|
|
||||||
|
|
||||||
Runs locally on a VPS node. Collects all system and service data in parallel and outputs a single JSON blob. Requires root privileges.
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# On a VPS node
|
|
||||||
sudo orama node report --json
|
|
||||||
```
|
|
||||||
|
|
||||||
### What It Collects
|
|
||||||
|
|
||||||
| Section | Data |
|
|
||||||
|---------|------|
|
|
||||||
| **system** | CPU count, load average, memory/disk/swap usage, OOM kills, kernel version, uptime, clock time |
|
|
||||||
| **services** | Systemd service states (active, restarts, memory, CPU, restart loop detection) for 10 core services |
|
|
||||||
| **rqlite** | Raft state, leader, term, applied/commit index, peers, strong read test, readyz, debug vars |
|
|
||||||
| **olric** | Service state, memberlist, member count, restarts, memory, log analysis |
|
|
||||||
| **ipfs** | Daemon/cluster state, swarm/cluster peers, repo size, versions, swarm key |
|
|
||||||
| **gateway** | HTTP health check, subsystem status |
|
|
||||||
| **wireguard** | Interface state, WG IP, peers, handshake ages, MTU, config permissions |
|
|
||||||
| **dns** | CoreDNS/Caddy state, port bindings, resolution tests, TLS cert expiry |
|
|
||||||
| **anyone** | Relay/client state, bootstrap progress, fingerprint |
|
|
||||||
| **network** | Internet reachability, TCP stats, retransmission rate, listening ports, UFW rules |
|
|
||||||
| **processes** | Zombie count, orphan orama processes, panic/fatal count in logs |
|
|
||||||
| **namespaces** | Per-namespace service probes (RQLite, Olric, Gateway) |
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
All 12 collectors run in parallel with goroutines. Typical collection time is **< 1 second** per node. HTTP timeouts are 3 seconds, command timeouts are 4 seconds.
|
|
||||||
|
|
||||||
### Output Schema
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2026-02-16T12:00:00Z",
|
|
||||||
"hostname": "ns1",
|
|
||||||
"version": "0.107.0",
|
|
||||||
"collect_ms": 526,
|
|
||||||
"errors": [],
|
|
||||||
"system": { "cpu_count": 4, "load_avg_1": 0.1, "mem_total_mb": 7937, ... },
|
|
||||||
"services": { "services": [...], "failed_units": [] },
|
|
||||||
"rqlite": { "responsive": true, "raft_state": "Leader", "term": 42, ... },
|
|
||||||
"olric": { "service_active": true, "memberlist_up": true, ... },
|
|
||||||
"ipfs": { "daemon_active": true, "swarm_peers": 2, ... },
|
|
||||||
"gateway": { "responsive": true, "http_status": 200, ... },
|
|
||||||
"wireguard": { "interface_up": true, "wg_ip": "10.0.0.1", "peers": [...], ... },
|
|
||||||
"dns": { "coredns_active": true, "caddy_active": true, "base_tls_days_left": 88, ... },
|
|
||||||
"anyone": { "relay_active": true, "bootstrapped": true, ... },
|
|
||||||
"network": { "internet_reachable": true, "ufw_active": true, ... },
|
|
||||||
"processes": { "zombie_count": 0, "orphan_count": 0, "panic_count": 0, ... },
|
|
||||||
"namespaces": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Alert Detection
|
|
||||||
|
|
||||||
Alerts are derived from cross-node analysis of all collected reports. Each alert has a severity level and identifies the affected subsystem and node.
|
|
||||||
|
|
||||||
### Alert Severities
|
|
||||||
|
|
||||||
| Severity | Examples |
|
|
||||||
|----------|----------|
|
|
||||||
| **critical** | SSH collection failed (node unreachable), no RQLite leader, split brain, RQLite unresponsive, WireGuard interface down, WG peer never handshaked, OOM kills, service failed, UFW inactive |
|
|
||||||
| **warning** | Strong read failed, memory > 90%, disk > 85%, stale WG handshake (> 3min), Raft term inconsistency, applied index lag > 100, restart loop detected, TLS cert < 14 days, DNS down, namespace gateway down, Anyone not bootstrapped, clock skew > 5s, binary version mismatch, internet unreachable, high TCP retransmission |
|
|
||||||
| **info** | Zombie processes, orphan orama processes, swap usage > 30% |
|
|
||||||
|
|
||||||
### Cross-Node Checks
|
|
||||||
|
|
||||||
These checks compare data across all nodes:
|
|
||||||
|
|
||||||
- **RQLite Leader**: Exactly one leader exists (no split brain)
|
|
||||||
- **Leader Agreement**: All nodes agree on the same leader address
|
|
||||||
- **Raft Term Consistency**: Term values within 1 of each other
|
|
||||||
- **Applied Index Lag**: Followers within 100 entries of the leader
|
|
||||||
- **WireGuard Peer Symmetry**: Each node has N-1 peers
|
|
||||||
- **Clock Skew**: Node clocks within 5 seconds of each other
|
|
||||||
- **Binary Version**: All nodes running the same version
|
|
||||||
- **WebRTC SFU Coverage**: SFU running on expected nodes (3/3) per namespace
|
|
||||||
- **WebRTC TURN Redundancy**: TURN running on expected nodes (2/3) per namespace
|
|
||||||
|
|
||||||
### Per-Node Checks
|
|
||||||
|
|
||||||
- **RQLite**: Responsive, ready, strong read
|
|
||||||
- **WireGuard**: Interface up, handshake freshness
|
|
||||||
- **System**: Memory, disk, load, OOM kills, swap
|
|
||||||
- **Services**: Systemd state, restart loops
|
|
||||||
- **DNS**: CoreDNS/Caddy up, TLS cert expiry, SOA resolution
|
|
||||||
- **Anyone**: Bootstrap progress
|
|
||||||
- **Processes**: Zombies, orphans, panics in logs
|
|
||||||
- **Namespaces**: Gateway and RQLite per namespace
|
|
||||||
- **WebRTC**: SFU and TURN service health (when provisioned)
|
|
||||||
- **Network**: UFW, internet reachability, TCP retransmission
|
|
||||||
|
|
||||||
## Monitor vs Inspector
|
|
||||||
|
|
||||||
Both tools check cluster health, but they serve different purposes:
|
|
||||||
|
|
||||||
| | `orama monitor` | `orama inspect` |
|
|
||||||
|---|---|---|
|
|
||||||
| **Data source** | `orama node report --json` (single SSH call per node) | 15+ SSH commands per node per subsystem |
|
|
||||||
| **Speed** | ~3-5s for full cluster | ~4-10s for full cluster |
|
|
||||||
| **Output** | TUI, tables, JSON | Tables, JSON |
|
|
||||||
| **Focus** | Real-time monitoring, alert detection | Deep diagnostic checks with pass/fail/warn |
|
|
||||||
| **AI support** | `report` subcommand for LLM input | `--ai` flag for inline analysis |
|
|
||||||
| **Use case** | "Is anything wrong right now?" | "What exactly is wrong and why?" |
|
|
||||||
|
|
||||||
Use `monitor` for day-to-day health checks and the interactive TUI. Use `inspect` for deep diagnostics when something is already known to be broken.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Uses the same `scripts/remote-nodes.conf` as the inspector. See [INSPECTOR.md](INSPECTOR.md#configuration) for format details.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
Nodes must have the `orama` CLI installed (via `orama node install` or `upload-source.sh`). The monitor runs `sudo orama node report --json` over SSH, so the binary must be at `/usr/local/bin/orama` on each node.
|
|
||||||
@ -1,233 +0,0 @@
|
|||||||
# OramaOS Deployment Guide
|
|
||||||
|
|
||||||
OramaOS is a custom minimal Linux image built with Buildroot. It replaces the standard Ubuntu-based node deployment for mainnet, devnet, and testnet environments. Sandbox clusters remain on Ubuntu for development convenience.
|
|
||||||
|
|
||||||
## What is OramaOS?
|
|
||||||
|
|
||||||
OramaOS is a locked-down operating system designed specifically for Orama node operators. Key properties:
|
|
||||||
|
|
||||||
- **No SSH, no shell** — operators cannot access the filesystem or run commands on the machine
|
|
||||||
- **LUKS full-disk encryption** — the data partition is encrypted; the key is split via Shamir's Secret Sharing across peer nodes
|
|
||||||
- **Read-only rootfs** — the OS image uses SquashFS with dm-verity integrity verification
|
|
||||||
- **A/B partition updates** — signed OS images are applied atomically with automatic rollback on failure
|
|
||||||
- **Service sandboxing** — each service runs in its own Linux namespace with seccomp syscall filtering
|
|
||||||
- **Signed binaries** — all updates are cryptographically signed with the Orama rootwallet
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Partition Layout:
|
|
||||||
/dev/sda1 — ESP (EFI System Partition, systemd-boot)
|
|
||||||
/dev/sda2 — rootfs-A (SquashFS, read-only, dm-verity)
|
|
||||||
/dev/sda3 — rootfs-B (standby, for A/B updates)
|
|
||||||
/dev/sda4 — data (LUKS2 encrypted, ext4)
|
|
||||||
|
|
||||||
Boot Flow:
|
|
||||||
systemd-boot → dm-verity rootfs → orama-agent → WireGuard → services
|
|
||||||
```
|
|
||||||
|
|
||||||
The **orama-agent** is the only root process. It manages:
|
|
||||||
- Boot sequence and LUKS key reconstruction
|
|
||||||
- WireGuard tunnel setup
|
|
||||||
- Service lifecycle (start, stop, restart in sandboxed namespaces)
|
|
||||||
- Command reception from the Gateway over WireGuard
|
|
||||||
- OS updates (download, verify signature, A/B swap, reboot)
|
|
||||||
|
|
||||||
## Enrollment Flow
|
|
||||||
|
|
||||||
OramaOS nodes join the cluster through an enrollment process (different from the Ubuntu `orama node install` flow):
|
|
||||||
|
|
||||||
### Step 1: Flash OramaOS to VPS
|
|
||||||
|
|
||||||
Download the OramaOS image and flash it to your VPS:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Download image (URL provided upon acceptance)
|
|
||||||
wget https://releases.orama.network/oramaos-v1.0.0-amd64.qcow2
|
|
||||||
|
|
||||||
# Flash to VPS (provider-specific — Hetzner, Vultr, etc.)
|
|
||||||
# Most providers support uploading custom images via their dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: First Boot — Enrollment Mode
|
|
||||||
|
|
||||||
On first boot, the agent:
|
|
||||||
1. Generates a random 8-character registration code
|
|
||||||
2. Starts a temporary HTTP server on port 9999
|
|
||||||
3. Opens an outbound WebSocket to the Gateway
|
|
||||||
4. Waits for enrollment to complete
|
|
||||||
|
|
||||||
The registration code is displayed on the VPS console (if available) and served at `http://<vps-ip>:9999/`.
|
|
||||||
|
|
||||||
### Step 3: Run Enrollment from CLI
|
|
||||||
|
|
||||||
On your local machine (where you have the `orama` CLI and rootwallet):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate an invite token on any existing cluster node
|
|
||||||
orama node invite --expiry 24h
|
|
||||||
|
|
||||||
# Enroll the OramaOS node
|
|
||||||
orama node enroll --node-ip <vps-public-ip> --token <invite-token> --gateway <gateway-url>
|
|
||||||
```
|
|
||||||
|
|
||||||
The enrollment command:
|
|
||||||
1. Fetches the registration code from the node (port 9999)
|
|
||||||
2. Sends the code + invite token to the Gateway
|
|
||||||
3. Gateway validates everything, assigns a WireGuard IP, and pushes config to the node
|
|
||||||
4. Node configures WireGuard, formats the LUKS-encrypted data partition
|
|
||||||
5. LUKS key is split via Shamir and distributed to peer vault-guardians
|
|
||||||
6. Services start in sandboxed namespaces
|
|
||||||
7. Port 9999 closes permanently
|
|
||||||
|
|
||||||
### Step 4: Verify
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check the node is online and healthy
|
|
||||||
orama monitor report --env <env>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Genesis Node
|
|
||||||
|
|
||||||
The first OramaOS node in a cluster is the **genesis node**. It has a special boot path because there are no peers yet for Shamir key distribution:
|
|
||||||
|
|
||||||
1. Genesis generates a LUKS key and encrypts the data partition
|
|
||||||
2. The LUKS key is encrypted with a rootwallet-derived key and stored on the unencrypted rootfs
|
|
||||||
3. On reboot (before enough peers exist), the operator must manually unlock:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
orama node unlock --genesis --node-ip <wg-ip>
|
|
||||||
```
|
|
||||||
|
|
||||||
This command:
|
|
||||||
1. Fetches the encrypted genesis key from the node
|
|
||||||
2. Decrypts it using the rootwallet (`rw decrypt`)
|
|
||||||
3. Sends the decrypted LUKS key to the agent over WireGuard
|
|
||||||
|
|
||||||
Once 5+ peers have joined, the genesis node distributes Shamir shares to peers, deletes the local encrypted key, and transitions to normal Shamir-based unlock. After this transition, `orama node unlock` is no longer needed.
|
|
||||||
|
|
||||||
## Normal Reboot (Shamir Unlock)
|
|
||||||
|
|
||||||
When an enrolled OramaOS node reboots:
|
|
||||||
|
|
||||||
1. Agent starts, brings up WireGuard
|
|
||||||
2. Contacts peer vault-guardians over WireGuard
|
|
||||||
3. Fetches K Shamir shares (K = threshold, typically `max(3, N/3)`)
|
|
||||||
4. Reconstructs LUKS key via Lagrange interpolation over GF(256)
|
|
||||||
5. Decrypts and mounts data partition
|
|
||||||
6. Starts all services
|
|
||||||
7. Zeros key from memory
|
|
||||||
|
|
||||||
If not enough peers are available, the agent enters a degraded "waiting for peers" state and retries with exponential backoff (1s, 2s, 4s, 8s, 16s, max 5 retries per cycle).
|
|
||||||
|
|
||||||
## Node Management
|
|
||||||
|
|
||||||
Since OramaOS has no SSH, all management happens through the Gateway API:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check node status
|
|
||||||
curl "https://gateway.example.com/v1/node/status?node_id=<id>"
|
|
||||||
|
|
||||||
# Send a command (e.g., restart a service)
|
|
||||||
curl -X POST "https://gateway.example.com/v1/node/command?node_id=<id>" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"action":"restart","service":"rqlite"}'
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
curl "https://gateway.example.com/v1/node/logs?node_id=<id>&service=gateway&lines=100"
|
|
||||||
|
|
||||||
# Graceful node departure
|
|
||||||
curl -X POST "https://gateway.example.com/v1/node/leave" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"node_id":"<id>"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
The Gateway proxies these requests to the agent over WireGuard (port 9998). The agent is never directly accessible from the public internet.
|
|
||||||
|
|
||||||
## OS Updates
|
|
||||||
|
|
||||||
OramaOS uses an A/B partition scheme for atomic, rollback-safe updates:
|
|
||||||
|
|
||||||
1. Agent periodically checks for new versions
|
|
||||||
2. Downloads the signed image (P2P over WireGuard between nodes)
|
|
||||||
3. Verifies the rootwallet EVM signature against the embedded public key
|
|
||||||
4. Writes to the standby partition (if running from A, writes to B)
|
|
||||||
5. Sets systemd-boot to boot from B with `tries_left=3`
|
|
||||||
6. Reboots
|
|
||||||
7. If B boots successfully (agent starts, WG connects, services healthy): marks B as "good"
|
|
||||||
8. If B fails 3 times: systemd-boot automatically falls back to A
|
|
||||||
|
|
||||||
No operator intervention is needed for updates. Failed updates are automatically rolled back.
|
|
||||||
|
|
||||||
## Service Sandboxing
|
|
||||||
|
|
||||||
Each service on OramaOS runs in an isolated environment:
|
|
||||||
|
|
||||||
- **Mount namespace** — each service only sees its own data directory as writable; everything else is read-only
|
|
||||||
- **UTS namespace** — isolated hostname
|
|
||||||
- **Dedicated UID/GID** — each service runs as a different user (not root)
|
|
||||||
- **Seccomp filtering** — per-service syscall allowlist (initially in audit mode, then enforce mode)
|
|
||||||
|
|
||||||
Services and their sandbox profiles:
|
|
||||||
| Service | Writable Path | Extra Syscalls |
|
|
||||||
|---------|--------------|----------------|
|
|
||||||
| RQLite | `/opt/orama/.orama/data/rqlite` | fsync, fdatasync (Raft + SQLite WAL) |
|
|
||||||
| Olric | `/opt/orama/.orama/data/olric` | sendmmsg, recvmmsg (gossip) |
|
|
||||||
| IPFS | `/opt/orama/.orama/data/ipfs` | sendfile, splice (data transfer) |
|
|
||||||
| Gateway | `/opt/orama/.orama/data/gateway` | sendfile, splice (HTTP) |
|
|
||||||
| CoreDNS | `/opt/orama/.orama/data/coredns` | sendmmsg, recvmmsg (DNS) |
|
|
||||||
|
|
||||||
## OramaOS vs Ubuntu Deployment
|
|
||||||
|
|
||||||
| Feature | Ubuntu | OramaOS |
|
|
||||||
|---------|--------|---------|
|
|
||||||
| SSH access | Yes | No |
|
|
||||||
| Shell access | Yes | No |
|
|
||||||
| Disk encryption | No | LUKS2 (Shamir) |
|
|
||||||
| OS updates | Manual (`orama node upgrade`) | Automatic (signed, A/B) |
|
|
||||||
| Service isolation | systemd only | Namespaces + seccomp |
|
|
||||||
| Rootfs integrity | None | dm-verity |
|
|
||||||
| Binary signing | Optional | Required |
|
|
||||||
| Operator data access | Full | None |
|
|
||||||
| Environments | All (including sandbox) | Mainnet, devnet, testnet |
|
|
||||||
|
|
||||||
## Cleaning / Factory Reset
|
|
||||||
|
|
||||||
OramaOS nodes cannot be cleaned with the standard `orama node clean` command (no SSH access). Instead:
|
|
||||||
|
|
||||||
- **Graceful departure:** `orama node leave` via the Gateway API — stops services, redistributes Shamir shares, removes WG peer
|
|
||||||
- **Factory reset:** Reflash the OramaOS image on the VPS via the hosting provider's dashboard
|
|
||||||
- **Data is unrecoverable:** Since the LUKS key is distributed across peers, reflashing destroys all data permanently
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Node stuck in enrollment mode
|
|
||||||
The node boots but enrollment never completes.
|
|
||||||
|
|
||||||
**Check:** Can you reach `http://<vps-ip>:9999/` from your machine? If not, the VPS firewall may be blocking port 9999.
|
|
||||||
|
|
||||||
**Fix:** Ensure port 9999 is open in the VPS provider's firewall. OramaOS opens it automatically via its internal firewall, but external provider firewalls (Hetzner, AWS security groups) must be configured separately.
|
|
||||||
|
|
||||||
### LUKS unlock fails (not enough peers)
|
|
||||||
After reboot, the node can't reconstruct its LUKS key.
|
|
||||||
|
|
||||||
**Check:** How many peer nodes are online? The node needs at least K peers (threshold) to be reachable over WireGuard.
|
|
||||||
|
|
||||||
**Fix:** Ensure enough cluster nodes are online. If this is the genesis node and fewer than 5 peers exist, use:
|
|
||||||
```bash
|
|
||||||
orama node unlock --genesis --node-ip <wg-ip>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update failed, node rolled back
|
|
||||||
The node applied an update but reverted to the previous version.
|
|
||||||
|
|
||||||
**Check:** The agent logs will show why the new partition failed to boot (accessible via `GET /v1/node/logs?service=agent`).
|
|
||||||
|
|
||||||
**Common causes:** Corrupted download (signature verification should catch this), hardware issue, or incompatible configuration.
|
|
||||||
|
|
||||||
### Services not starting after reboot
|
|
||||||
The node rebooted and LUKS unlocked, but services are unhealthy.
|
|
||||||
|
|
||||||
**Check:** `GET /v1/node/status` — which services are down?
|
|
||||||
|
|
||||||
**Fix:** Try restarting the specific service via `POST /v1/node/command` with `{"action":"restart","service":"<name>"}`. If the issue persists, check service logs.
|
|
||||||
@ -1,208 +0,0 @@
|
|||||||
# Sandbox: Ephemeral Hetzner Cloud Clusters
|
|
||||||
|
|
||||||
Spin up temporary 5-node Orama clusters on Hetzner Cloud for development and testing. Total cost: ~€0.04/hour.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# One-time setup (API key, domain, floating IPs, SSH key)
|
|
||||||
orama sandbox setup
|
|
||||||
|
|
||||||
# Create a cluster (~5 minutes)
|
|
||||||
orama sandbox create --name my-feature
|
|
||||||
|
|
||||||
# Check health
|
|
||||||
orama sandbox status
|
|
||||||
|
|
||||||
# SSH into a node
|
|
||||||
orama sandbox ssh 1
|
|
||||||
|
|
||||||
# Deploy code changes
|
|
||||||
orama sandbox rollout
|
|
||||||
|
|
||||||
# Tear it down
|
|
||||||
orama sandbox destroy
|
|
||||||
```
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
### 1. Hetzner Cloud Account
|
|
||||||
|
|
||||||
Create a project at [console.hetzner.cloud](https://console.hetzner.cloud) and generate an API token with read/write permissions under **Security > API Tokens**.
|
|
||||||
|
|
||||||
### 2. Domain with Glue Records
|
|
||||||
|
|
||||||
You need a domain (or subdomain) that points to Hetzner Floating IPs. The `orama sandbox setup` wizard will guide you through this.
|
|
||||||
|
|
||||||
**Example:** Using `sbx.dbrs.space`
|
|
||||||
|
|
||||||
At your domain registrar:
|
|
||||||
1. Create glue records (Personal DNS Servers):
|
|
||||||
- `ns1.sbx.dbrs.space` → `<floating-ip-1>`
|
|
||||||
- `ns2.sbx.dbrs.space` → `<floating-ip-2>`
|
|
||||||
2. Set custom nameservers for `sbx.dbrs.space`:
|
|
||||||
- `ns1.sbx.dbrs.space`
|
|
||||||
- `ns2.sbx.dbrs.space`
|
|
||||||
|
|
||||||
DNS propagation can take up to 48 hours.
|
|
||||||
|
|
||||||
### 3. Binary Archive
|
|
||||||
|
|
||||||
Build the binary archive before creating a cluster:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
orama build
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates `/tmp/orama-<version>-linux-amd64.tar.gz` with all pre-compiled binaries.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
Run the interactive setup wizard:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
orama sandbox setup
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
1. Prompt for your Hetzner API token and validate it
|
|
||||||
2. Ask for your sandbox domain
|
|
||||||
3. Create or reuse 2 Hetzner Floating IPs (~$0.005/hr each)
|
|
||||||
4. Create a firewall with sandbox rules
|
|
||||||
5. Create a rootwallet SSH entry (`sandbox/root`) if it doesn't exist
|
|
||||||
6. Upload the wallet-derived public key to Hetzner
|
|
||||||
7. Display DNS configuration instructions
|
|
||||||
|
|
||||||
Config is saved to `~/.orama/sandbox.yaml`.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
### `orama sandbox create [--name <name>]`
|
|
||||||
|
|
||||||
Creates a new 5-node cluster. If `--name` is omitted, a random name is generated (e.g., "swift-falcon").
|
|
||||||
|
|
||||||
**Cluster layout:**
|
|
||||||
- Nodes 1-2: Nameservers (CoreDNS + Caddy + all services)
|
|
||||||
- Nodes 3-5: Regular nodes (all services except CoreDNS)
|
|
||||||
|
|
||||||
**Phases:**
|
|
||||||
1. Provision 5 CX22 servers on Hetzner (parallel, ~90s)
|
|
||||||
2. Assign floating IPs to nameserver nodes (~10s)
|
|
||||||
3. Upload binary archive to all nodes (parallel, ~60s)
|
|
||||||
4. Install genesis node + generate invite tokens (~120s)
|
|
||||||
5. Join remaining 4 nodes (serial with health checks, ~180s)
|
|
||||||
6. Verify cluster health (~15s)
|
|
||||||
|
|
||||||
**One sandbox at a time.** Since the floating IPs are shared, only one sandbox can own the nameservers. Destroy the active sandbox before creating a new one.
|
|
||||||
|
|
||||||
### `orama sandbox destroy [--name <name>] [--force]`
|
|
||||||
|
|
||||||
Tears down a cluster:
|
|
||||||
1. Unassigns floating IPs
|
|
||||||
2. Deletes all 5 servers (parallel)
|
|
||||||
3. Removes state file
|
|
||||||
|
|
||||||
Use `--force` to skip confirmation.
|
|
||||||
|
|
||||||
### `orama sandbox list`
|
|
||||||
|
|
||||||
Lists all sandboxes with their status. Also checks Hetzner for orphaned servers that don't have a corresponding state file.
|
|
||||||
|
|
||||||
### `orama sandbox status [--name <name>]`
|
|
||||||
|
|
||||||
Shows per-node health including:
|
|
||||||
- Service status (active/inactive)
|
|
||||||
- RQLite role (Leader/Follower)
|
|
||||||
- Cluster summary (commit index, voter count)
|
|
||||||
|
|
||||||
### `orama sandbox rollout [--name <name>]`
|
|
||||||
|
|
||||||
Deploys code changes:
|
|
||||||
1. Uses the latest binary archive from `/tmp/` (run `orama build` first)
|
|
||||||
2. Pushes to all nodes
|
|
||||||
3. Rolling upgrade: followers first, leader last, 15s between nodes
|
|
||||||
|
|
||||||
### `orama sandbox ssh <node-number>`
|
|
||||||
|
|
||||||
Opens an interactive SSH session to a sandbox node (1-5).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
orama sandbox ssh 1 # SSH into node 1 (genesis/ns1)
|
|
||||||
orama sandbox ssh 3 # SSH into node 3 (regular node)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Floating IPs
|
|
||||||
|
|
||||||
Hetzner Floating IPs are persistent IPv4 addresses that can be reassigned between servers. They solve the DNS chicken-and-egg problem:
|
|
||||||
|
|
||||||
- Glue records at the registrar point to 2 Floating IPs (configured once)
|
|
||||||
- Each new sandbox assigns the Floating IPs to its nameserver nodes
|
|
||||||
- DNS works instantly — no propagation delay between clusters
|
|
||||||
|
|
||||||
### SSH Authentication
|
|
||||||
|
|
||||||
Sandbox uses a rootwallet-derived SSH key (`sandbox/root` vault entry), the same mechanism as production. The wallet must be unlocked (`rw unlock`) before running sandbox commands that use SSH. The public key is uploaded to Hetzner during setup and injected into every server at creation time.
|
|
||||||
|
|
||||||
### Server Naming
|
|
||||||
|
|
||||||
Servers: `sbx-<name>-<N>` (e.g., `sbx-swift-falcon-1` through `sbx-swift-falcon-5`)
|
|
||||||
|
|
||||||
### State Files
|
|
||||||
|
|
||||||
Sandbox state is stored at `~/.orama/sandboxes/<name>.yaml`. This tracks server IDs, IPs, roles, and cluster status.
|
|
||||||
|
|
||||||
## Cost
|
|
||||||
|
|
||||||
| Resource | Cost | Qty | Total |
|
|
||||||
|----------|------|-----|-------|
|
|
||||||
| CX22 (2 vCPU, 4GB) | €0.006/hr | 5 | €0.03/hr |
|
|
||||||
| Floating IPv4 | €0.005/hr | 2 | €0.01/hr |
|
|
||||||
| **Total** | | | **~€0.04/hr** |
|
|
||||||
|
|
||||||
Servers are billed per hour. Floating IPs are billed as long as they exist (even unassigned). Destroy the sandbox when not in use to save on server costs.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "sandbox not configured"
|
|
||||||
|
|
||||||
Run `orama sandbox setup` first.
|
|
||||||
|
|
||||||
### "no binary archive found"
|
|
||||||
|
|
||||||
Run `orama build` to create the binary archive.
|
|
||||||
|
|
||||||
### "sandbox X is already active"
|
|
||||||
|
|
||||||
Only one sandbox can be active at a time. Destroy it first:
|
|
||||||
```bash
|
|
||||||
orama sandbox destroy --name <name>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server creation fails
|
|
||||||
|
|
||||||
Check:
|
|
||||||
- Hetzner API token is valid and has read/write permissions
|
|
||||||
- You haven't hit Hetzner's server limit (default: 10 per project)
|
|
||||||
- The selected location has CX22 capacity
|
|
||||||
|
|
||||||
### Genesis install fails
|
|
||||||
|
|
||||||
SSH into the node to debug:
|
|
||||||
```bash
|
|
||||||
orama sandbox ssh 1
|
|
||||||
journalctl -u orama-node -f
|
|
||||||
```
|
|
||||||
|
|
||||||
The sandbox will be left in "error" state. You can destroy and recreate it.
|
|
||||||
|
|
||||||
### DNS not resolving
|
|
||||||
|
|
||||||
1. Verify glue records are configured at your registrar
|
|
||||||
2. Check propagation: `dig NS sbx.dbrs.space @8.8.8.8`
|
|
||||||
3. Propagation can take 24-48 hours for new domains
|
|
||||||
|
|
||||||
### Orphaned servers
|
|
||||||
|
|
||||||
If `orama sandbox list` shows orphaned servers, delete them manually at [console.hetzner.cloud](https://console.hetzner.cloud). Sandbox servers are labeled `orama-sandbox=<name>` for easy identification.
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
# Security Hardening
|
|
||||||
|
|
||||||
This document describes all security measures applied to the Orama Network, covering both Phase 1 (service hardening on existing Ubuntu nodes) and Phase 2 (OramaOS locked-down image).
|
|
||||||
|
|
||||||
## Phase 1: Service Hardening
|
|
||||||
|
|
||||||
These measures apply to all nodes (Ubuntu and OramaOS).
|
|
||||||
|
|
||||||
### Network Isolation
|
|
||||||
|
|
||||||
**CIDR Validation (Step 1.1)**
|
|
||||||
- WireGuard subnet restricted to `10.0.0.0/24` across all components: firewall rules, rate limiter, auth module, and WireGuard PostUp/PostDown iptables rules
|
|
||||||
- Prevents other tenants on shared VPS providers from bypassing the firewall via overlapping `10.x.x.x` ranges
|
|
||||||
|
|
||||||
**IPv6 Disabled (Step 1.2)**
|
|
||||||
- IPv6 disabled system-wide via sysctl: `net.ipv6.conf.all.disable_ipv6=1`
|
|
||||||
- Prevents services bound to `0.0.0.0` from being reachable via IPv6 (which had no firewall rules)
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
|
|
||||||
**Internal Endpoint Auth (Step 1.3)**
|
|
||||||
- `/v1/internal/wg/peers` and `/v1/internal/wg/peer/remove` now require cluster secret validation
|
|
||||||
- Peer removal additionally validates the request originates from a WireGuard subnet IP
|
|
||||||
|
|
||||||
**RQLite Authentication (Step 1.7)**
|
|
||||||
- RQLite runs with `-auth` flag pointing to a credentials file
|
|
||||||
- All RQLite HTTP requests include `Authorization: Basic <base64>` headers
|
|
||||||
- Credentials generated at cluster genesis, distributed to joining nodes via join response
|
|
||||||
- Both the central RQLite client wrapper and the standalone CoreDNS RQLite client send auth
|
|
||||||
|
|
||||||
**Olric Gossip Encryption (Step 1.8)**
|
|
||||||
- Olric memberlist uses a 32-byte encryption key for all gossip traffic
|
|
||||||
- Key generated at genesis, distributed via join response
|
|
||||||
- Prevents rogue nodes from joining the gossip ring and poisoning caches
|
|
||||||
- Note: encryption is all-or-nothing (coordinated restart required when enabling)
|
|
||||||
|
|
||||||
**IPFS Cluster TrustedPeers (Step 1.9)**
|
|
||||||
- IPFS Cluster `TrustedPeers` populated with actual cluster peer IDs (was `["*"]`)
|
|
||||||
- New peers added to TrustedPeers on all existing nodes during join
|
|
||||||
- Prevents unauthorized peers from controlling IPFS pinning
|
|
||||||
|
|
||||||
**Vault V1 Auth Enforcement (Step 1.14)**
|
|
||||||
- V1 push/pull endpoints require a valid session token when vault-guardian is configured
|
|
||||||
- Previously, auth was optional for backward compatibility — any WG peer could read/overwrite Shamir shares
|
|
||||||
|
|
||||||
### Token & Key Storage
|
|
||||||
|
|
||||||
**Refresh Token Hashing (Step 1.5)**
|
|
||||||
- Refresh tokens stored as SHA-256 hashes in RQLite (never plaintext)
|
|
||||||
- On lookup: hash the incoming token, query by hash
|
|
||||||
- On revocation: hash before revoking (both single-token and by-subject)
|
|
||||||
- Existing tokens invalidated on upgrade (users re-authenticate)
|
|
||||||
|
|
||||||
**API Key Hashing (Step 1.6)**
|
|
||||||
- API keys stored as HMAC-SHA256 hashes using a server-side secret
|
|
||||||
- HMAC secret generated at cluster genesis, stored in `~/.orama/secrets/api-key-hmac-secret`
|
|
||||||
- On lookup: compute HMAC, query by hash — fast enough for every request (unlike bcrypt)
|
|
||||||
- In-memory cache uses raw key as cache key (never persisted)
|
|
||||||
- During rolling upgrade: dual lookup (HMAC first, then raw as fallback) until all nodes upgraded
|
|
||||||
|
|
||||||
**TURN Secret Encryption (Step 1.15)**
|
|
||||||
- TURN shared secrets encrypted at rest in RQLite using AES-256-GCM
|
|
||||||
- Encryption key derived via HKDF from the cluster secret with purpose string `"turn-encryption"`
|
|
||||||
|
|
||||||
### TLS & Transport
|
|
||||||
|
|
||||||
**InsecureSkipVerify Fix (Step 1.10)**
|
|
||||||
- During node join, TLS verification uses TOFU (Trust On First Use)
|
|
||||||
- Invite token output includes the CA certificate fingerprint (SHA-256)
|
|
||||||
- Joining node verifies the server cert fingerprint matches before proceeding
|
|
||||||
- After join: CA cert stored locally for future connections
|
|
||||||
|
|
||||||
**WebSocket Origin Validation (Step 1.4)**
|
|
||||||
- All WebSocket upgraders validate the `Origin` header against the node's configured domain
|
|
||||||
- Non-browser clients (no Origin header) are still allowed
|
|
||||||
- Prevents cross-site WebSocket hijacking attacks
|
|
||||||
|
|
||||||
### Process Isolation
|
|
||||||
|
|
||||||
**Dedicated User (Step 1.11)**
|
|
||||||
- All services run as the `orama` user (not root)
|
|
||||||
- Caddy and CoreDNS get `AmbientCapabilities=CAP_NET_BIND_SERVICE` for ports 80/443 and 53
|
|
||||||
- WireGuard stays as root (kernel netlink requires it)
|
|
||||||
- vault-guardian already had proper hardening
|
|
||||||
|
|
||||||
**systemd Hardening (Step 1.12)**
|
|
||||||
- All service units include:
|
|
||||||
```ini
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=yes
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
PrivateDevices=yes
|
|
||||||
ProtectKernelTunables=yes
|
|
||||||
ProtectKernelModules=yes
|
|
||||||
RestrictNamespaces=yes
|
|
||||||
ReadWritePaths=/opt/orama/.orama
|
|
||||||
```
|
|
||||||
- Applied to both template files (`pkg/environments/templates/`) and hardcoded unit generators (`pkg/environments/production/services.go`)
|
|
||||||
|
|
||||||
### Supply Chain
|
|
||||||
|
|
||||||
**Binary Signing (Step 1.13)**
|
|
||||||
- Build archives include `manifest.sig` — a rootwallet EVM signature of the manifest hash
|
|
||||||
- During install, the signature is verified against the embedded Orama public key
|
|
||||||
- Unsigned or tampered archives are rejected
|
|
||||||
|
|
||||||
## Phase 2: OramaOS
|
|
||||||
|
|
||||||
These measures apply only to OramaOS nodes (mainnet, devnet, testnet).
|
|
||||||
|
|
||||||
### Immutable OS
|
|
||||||
|
|
||||||
- **Read-only rootfs** — SquashFS with dm-verity integrity verification
|
|
||||||
- **No shell** — `/bin/sh` symlinked to `/bin/false`, no bash/ash/ssh
|
|
||||||
- **No SSH** — OpenSSH not included in the image
|
|
||||||
- **Minimal packages** — only what's needed for systemd, cryptsetup, and the agent
|
|
||||||
|
|
||||||
### Full-Disk Encryption
|
|
||||||
|
|
||||||
- **LUKS2** with AES-XTS-Plain64 on the data partition
|
|
||||||
- **Shamir's Secret Sharing** over GF(256) — LUKS key split across peer vault-guardians
|
|
||||||
- **Adaptive threshold** — K = max(3, N/3) where N is the number of peers
|
|
||||||
- **Key zeroing** — LUKS key wiped from memory immediately after use
|
|
||||||
- **Malicious share detection** — fetch K+1 shares when possible, verify consistency
|
|
||||||
|
|
||||||
### Service Sandboxing
|
|
||||||
|
|
||||||
Each service runs in isolated Linux namespaces:
|
|
||||||
- **CLONE_NEWNS** — mount namespace (filesystem isolation)
|
|
||||||
- **CLONE_NEWUTS** — hostname namespace
|
|
||||||
- **Dedicated UID/GID** — each service has its own user
|
|
||||||
- **Seccomp filtering** — per-service syscall allowlist
|
|
||||||
|
|
||||||
Note: CLONE_NEWPID is intentionally omitted — it makes services PID 1 in their namespace, which changes signal semantics (SIGTERM ignored by default for PID 1).
|
|
||||||
|
|
||||||
### Signed Updates
|
|
||||||
|
|
||||||
- A/B partition scheme with systemd-boot and boot counting (`tries_left=3`)
|
|
||||||
- All updates signed with rootwallet EVM signature (secp256k1 + keccak256)
|
|
||||||
- Signer address: `0xb5d8a496c8b2412990d7D467E17727fdF5954afC`
|
|
||||||
- P2P distribution over WireGuard between nodes
|
|
||||||
- Automatic rollback on 3 consecutive boot failures
|
|
||||||
|
|
||||||
### Zero Operator Access
|
|
||||||
|
|
||||||
- Operators cannot read data on the machine (LUKS encrypted, no shell)
|
|
||||||
- Management only through Gateway API → agent over WireGuard
|
|
||||||
- All commands are logged and auditable
|
|
||||||
- No root access, no console access, no file system access
|
|
||||||
|
|
||||||
## Rollout Strategy
|
|
||||||
|
|
||||||
### Phase 1 Batches
|
|
||||||
|
|
||||||
```
|
|
||||||
Batch 1 (zero-risk, no restart):
|
|
||||||
- CIDR fix
|
|
||||||
- IPv6 disable
|
|
||||||
- Internal endpoint auth
|
|
||||||
- WebSocket origin check
|
|
||||||
|
|
||||||
Batch 2 (medium-risk, restart needed):
|
|
||||||
- Hash refresh tokens
|
|
||||||
- Hash API keys
|
|
||||||
- Binary signing
|
|
||||||
- Vault V1 auth enforcement
|
|
||||||
- TURN secret encryption
|
|
||||||
|
|
||||||
Batch 3 (high-risk, coordinated rollout):
|
|
||||||
- RQLite auth (followers first, leader last)
|
|
||||||
- Olric encryption (simultaneous restart)
|
|
||||||
- IPFS Cluster TrustedPeers
|
|
||||||
|
|
||||||
Batch 4 (infrastructure changes):
|
|
||||||
- InsecureSkipVerify fix
|
|
||||||
- Dedicated user
|
|
||||||
- systemd hardening
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 2
|
|
||||||
|
|
||||||
1. Build and test OramaOS image in QEMU
|
|
||||||
2. Deploy to sandbox cluster alongside Ubuntu nodes
|
|
||||||
3. Verify interop and stability
|
|
||||||
4. Gradual migration: testnet → devnet → mainnet (one node at a time, maintaining Raft quorum)
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
All changes verified on sandbox cluster before production deployment:
|
|
||||||
|
|
||||||
- `make test` — all unit tests pass
|
|
||||||
- `orama monitor report --env sandbox` — full cluster health
|
|
||||||
- Manual endpoint testing (e.g., curl without auth → 401)
|
|
||||||
- Security-specific checks (IPv6 listeners, RQLite auth, binary signatures)
|
|
||||||
@ -1,374 +0,0 @@
|
|||||||
# Serverless Functions
|
|
||||||
|
|
||||||
Orama Network runs serverless functions as sandboxed WebAssembly (WASM) modules. Functions are written in Go, compiled to WASM with TinyGo, and executed in an isolated wazero runtime with configurable memory limits and timeouts.
|
|
||||||
|
|
||||||
Functions receive input via **stdin** (JSON) and return output via **stdout** (JSON). They can also access Orama services — database, cache, storage, secrets, PubSub, and HTTP — through **host functions** injected by the runtime.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Scaffold a new function
|
|
||||||
orama function init my-function
|
|
||||||
|
|
||||||
# 2. Edit your handler
|
|
||||||
cd my-function
|
|
||||||
# edit function.go
|
|
||||||
|
|
||||||
# 3. Build to WASM
|
|
||||||
orama function build
|
|
||||||
|
|
||||||
# 4. Deploy
|
|
||||||
orama function deploy
|
|
||||||
|
|
||||||
# 5. Invoke
|
|
||||||
orama function invoke my-function --data '{"name": "World"}'
|
|
||||||
|
|
||||||
# 6. View logs
|
|
||||||
orama function logs my-function
|
|
||||||
```
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
my-function/
|
|
||||||
├── function.go # Handler code
|
|
||||||
└── function.yaml # Configuration
|
|
||||||
```
|
|
||||||
|
|
||||||
### function.yaml
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
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)
|
|
||||||
retry:
|
|
||||||
count: 0 # Retry attempts on failure (default: 0)
|
|
||||||
delay: 5 # Seconds between retries (default: 5)
|
|
||||||
env: # Environment variables (accessible via get_env)
|
|
||||||
MY_VAR: "value"
|
|
||||||
```
|
|
||||||
|
|
||||||
### function.go (minimal)
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Read JSON input from stdin
|
|
||||||
var input []byte
|
|
||||||
buf := make([]byte, 4096)
|
|
||||||
for {
|
|
||||||
n, err := os.Stdin.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
input = append(input, buf[:n]...)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload map[string]interface{}
|
|
||||||
json.Unmarshal(input, &payload)
|
|
||||||
|
|
||||||
// Process and return JSON output via stdout
|
|
||||||
response := map[string]interface{}{
|
|
||||||
"result": "Hello!",
|
|
||||||
}
|
|
||||||
output, _ := json.Marshal(response)
|
|
||||||
os.Stdout.Write(output)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Building
|
|
||||||
|
|
||||||
Functions are compiled to WASM using [TinyGo](https://tinygo.org/):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Using the CLI (recommended)
|
|
||||||
orama function build
|
|
||||||
|
|
||||||
# Or manually
|
|
||||||
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.
|
|
||||||
|
|
||||||
All host functions are registered at runtime by the engine. They are available to every function without additional configuration.
|
|
||||||
|
|
||||||
### Context
|
|
||||||
|
|
||||||
| Function | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `get_caller_wallet()` → string | Wallet address of the caller (from JWT) |
|
|
||||||
| `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)) |
|
|
||||||
|
|
||||||
### Database (RQLite)
|
|
||||||
|
|
||||||
| 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. |
|
|
||||||
|
|
||||||
Example query from WASM:
|
|
||||||
```
|
|
||||||
db_query("SELECT push_token, device_type FROM devices WHERE user_id = ?", '["user123"]')
|
|
||||||
→ [{"push_token": "abc...", "device_type": "ios"}]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cache (Olric Distributed Cache)
|
|
||||||
|
|
||||||
| Function | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `cache_get(key)` → bytes | Get cached value by key. Returns empty on miss. |
|
|
||||||
| `cache_set(key, value, ttl)` | Store value with TTL in seconds. |
|
|
||||||
| `cache_incr(key)` → int64 | Atomically increment by 1 (init to 0 if missing). |
|
|
||||||
| `cache_incr_by(key, delta)` → int64 | Atomically increment by delta. |
|
|
||||||
|
|
||||||
### HTTP
|
|
||||||
|
|
||||||
| Function | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `http_fetch(method, url, headersJSON, body)` → JSON | Make outbound HTTP request. Headers as JSON object. Returns `{"status": 200, "headers": {...}, "body": "..."}`. Timeout: 30s. |
|
|
||||||
|
|
||||||
### PubSub
|
|
||||||
|
|
||||||
| Function | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `pubsub_publish(topic, dataJSON)` → bool | Publish message to a PubSub topic. Returns true on success. |
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
|
|
||||||
| Function | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `log_info(message)` | Log info-level message (captured in invocation logs). |
|
|
||||||
| `log_error(message)` | Log error-level message. |
|
|
||||||
|
|
||||||
## Managing Secrets
|
|
||||||
|
|
||||||
Secrets are encrypted at rest (AES-256-GCM) and scoped to your namespace. Functions read them via `get_secret("name")` at runtime.
|
|
||||||
|
|
||||||
### CLI Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Set a secret (inline value)
|
|
||||||
orama function secrets set APNS_KEY_ID "ABC123DEF"
|
|
||||||
|
|
||||||
# Set a secret from a file (useful for PEM keys, certificates)
|
|
||||||
orama function secrets set APNS_AUTH_KEY --from-file ./AuthKey_ABC123.p8
|
|
||||||
|
|
||||||
# List all secret names (values are never shown)
|
|
||||||
orama function secrets list
|
|
||||||
|
|
||||||
# Delete a secret
|
|
||||||
orama function secrets delete APNS_KEY_ID
|
|
||||||
|
|
||||||
# Delete without confirmation
|
|
||||||
orama function secrets delete APNS_KEY_ID --force
|
|
||||||
```
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
1. **You set secrets** via the CLI → encrypted and stored in the database
|
|
||||||
2. **Functions read secrets** at runtime via `get_secret("name")` → decrypted on demand
|
|
||||||
3. **Namespace isolation** → each namespace has its own secret store; functions in namespace A cannot read secrets from namespace B
|
|
||||||
|
|
||||||
## PubSub Triggers
|
|
||||||
|
|
||||||
Triggers let functions react to events automatically. When a message is published to a PubSub topic, all functions with a trigger on that topic are invoked asynchronously.
|
|
||||||
|
|
||||||
### CLI Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add a trigger: invoke "call-push-handler" when messages hit "calls:invite"
|
|
||||||
orama function triggers add call-push-handler --topic calls:invite
|
|
||||||
|
|
||||||
# List triggers for a function
|
|
||||||
orama function triggers list call-push-handler
|
|
||||||
|
|
||||||
# Delete a trigger
|
|
||||||
orama function triggers delete call-push-handler <trigger-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Trigger Event Payload
|
|
||||||
|
|
||||||
When triggered via PubSub, the function receives this JSON via stdin:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"topic": "calls:invite",
|
|
||||||
"data": { ... },
|
|
||||||
"namespace": "my-namespace",
|
|
||||||
"trigger_depth": 1,
|
|
||||||
"timestamp": 1708972800
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Depth Limiting
|
|
||||||
|
|
||||||
To prevent infinite loops (function A publishes to topic → triggers function A again), trigger depth is tracked. Maximum depth is **5**. If a function's output triggers another function, `trigger_depth` increments. At depth 5, no further triggers fire.
|
|
||||||
|
|
||||||
## Function Lifecycle
|
|
||||||
|
|
||||||
### Versioning
|
|
||||||
|
|
||||||
Each deploy creates a new version. The WASM binary is stored in **IPFS** (content-addressed) and metadata is stored in **RQLite**.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List versions
|
|
||||||
orama function versions my-function
|
|
||||||
|
|
||||||
# Invoke a specific version
|
|
||||||
curl -X POST .../v1/functions/my-function@2/invoke
|
|
||||||
```
|
|
||||||
|
|
||||||
### Invocation Logging
|
|
||||||
|
|
||||||
Every invocation is logged with: request ID, duration, status (success/error/timeout), input/output size, and any `log_info`/`log_error` messages.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
orama function logs my-function
|
|
||||||
```
|
|
||||||
|
|
||||||
## CLI Reference
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `orama function init <name>` | Scaffold a new function project |
|
|
||||||
| `orama function build [dir]` | Compile Go to WASM |
|
|
||||||
| `orama function deploy [dir]` | Deploy WASM to the network |
|
|
||||||
| `orama function invoke <name> --data <json>` | Invoke a function |
|
|
||||||
| `orama function list` | List deployed functions |
|
|
||||||
| `orama function get <name>` | Get function details |
|
|
||||||
| `orama function delete <name>` | Delete a function |
|
|
||||||
| `orama function logs <name>` | View invocation logs |
|
|
||||||
| `orama function versions <name>` | List function versions |
|
|
||||||
| `orama function secrets set <name> <value>` | Set an encrypted secret |
|
|
||||||
| `orama function secrets list` | List secret names |
|
|
||||||
| `orama function secrets delete <name>` | Delete a secret |
|
|
||||||
| `orama function triggers add <fn> --topic <t>` | Add PubSub trigger |
|
|
||||||
| `orama function triggers list <fn>` | List triggers |
|
|
||||||
| `orama function triggers delete <fn> <id>` | Delete a trigger |
|
|
||||||
|
|
||||||
## HTTP API Reference
|
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
|
||||||
|--------|----------|-------------|
|
|
||||||
| POST | `/v1/functions` | Deploy function (multipart/form-data) |
|
|
||||||
| GET | `/v1/functions` | List functions |
|
|
||||||
| GET | `/v1/functions/{name}` | Get function info |
|
|
||||||
| DELETE | `/v1/functions/{name}` | Delete function |
|
|
||||||
| POST | `/v1/functions/{name}/invoke` | Invoke function |
|
|
||||||
| GET | `/v1/functions/{name}/versions` | List versions |
|
|
||||||
| GET | `/v1/functions/{name}/logs` | Get logs |
|
|
||||||
| WS | `/v1/functions/{name}/ws` | WebSocket invoke (streaming) |
|
|
||||||
| PUT | `/v1/functions/secrets` | Set a secret |
|
|
||||||
| GET | `/v1/functions/secrets` | List secret names |
|
|
||||||
| DELETE | `/v1/functions/secrets/{name}` | Delete a secret |
|
|
||||||
| POST | `/v1/functions/{name}/triggers` | Add PubSub trigger |
|
|
||||||
| GET | `/v1/functions/{name}/triggers` | List triggers |
|
|
||||||
| DELETE | `/v1/functions/{name}/triggers/{id}` | Delete trigger |
|
|
||||||
| POST | `/v1/invoke/{namespace}/{name}` | Direct invoke (alt endpoint) |
|
|
||||||
|
|
||||||
## Example: Call Push Handler
|
|
||||||
|
|
||||||
A real-world function that sends VoIP push notifications when a call invite is published to PubSub:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# function.yaml
|
|
||||||
name: call-push-handler
|
|
||||||
memory: 128
|
|
||||||
timeout: 30
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
// function.go — triggered by PubSub on "calls:invite"
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This function:
|
|
||||||
// 1. Receives a call invite event from PubSub trigger
|
|
||||||
// 2. Queries the database for the callee's device info
|
|
||||||
// 3. Reads push notification credentials from secrets
|
|
||||||
// 4. Sends a push notification via http_fetch
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Read PubSub trigger event from stdin
|
|
||||||
var input []byte
|
|
||||||
buf := make([]byte, 4096)
|
|
||||||
for {
|
|
||||||
n, err := os.Stdin.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
input = append(input, buf[:n]...)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the trigger event wrapper
|
|
||||||
var event struct {
|
|
||||||
Topic string `json:"topic"`
|
|
||||||
Data json.RawMessage `json:"data"`
|
|
||||||
}
|
|
||||||
json.Unmarshal(input, &event)
|
|
||||||
|
|
||||||
// Parse the actual call invite data
|
|
||||||
var invite struct {
|
|
||||||
CalleeID string `json:"calleeId"`
|
|
||||||
CallerName string `json:"callerName"`
|
|
||||||
CallType string `json:"callType"`
|
|
||||||
}
|
|
||||||
json.Unmarshal(event.Data, &invite)
|
|
||||||
|
|
||||||
// At this point, the function would use host functions:
|
|
||||||
//
|
|
||||||
// 1. db_query("SELECT push_token, device_type FROM devices WHERE user_id = ?",
|
|
||||||
// json.Marshal([]string{invite.CalleeID}))
|
|
||||||
//
|
|
||||||
// 2. get_secret("FCM_SERVER_KEY") for Android push
|
|
||||||
// get_secret("APNS_KEY_PEM") for iOS push
|
|
||||||
//
|
|
||||||
// 3. http_fetch("POST", "https://fcm.googleapis.com/v1/...", headers, body)
|
|
||||||
//
|
|
||||||
// 4. log_info("Push sent to " + invite.CalleeID)
|
|
||||||
//
|
|
||||||
// Note: Host functions use the WASM ABI (pointer/length).
|
|
||||||
// A Go SDK for ergonomic access is planned.
|
|
||||||
|
|
||||||
response := map[string]interface{}{
|
|
||||||
"status": "sent",
|
|
||||||
"callee": invite.CalleeID,
|
|
||||||
}
|
|
||||||
output, _ := json.Marshal(response)
|
|
||||||
os.Stdout.Write(output)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Deploy and wire the trigger:
|
|
||||||
```bash
|
|
||||||
orama function build
|
|
||||||
orama function deploy
|
|
||||||
|
|
||||||
# Set push notification secrets
|
|
||||||
orama function secrets set FCM_SERVER_KEY "your-fcm-key"
|
|
||||||
orama function secrets set APNS_KEY_PEM --from-file ./AuthKey.p8
|
|
||||||
orama function secrets set APNS_KEY_ID "ABC123"
|
|
||||||
orama function secrets set APNS_TEAM_ID "TEAM456"
|
|
||||||
|
|
||||||
# Wire the PubSub trigger
|
|
||||||
orama function triggers add call-push-handler --topic calls:invite
|
|
||||||
```
|
|
||||||
@ -1,291 +0,0 @@
|
|||||||
# WebRTC Integration
|
|
||||||
|
|
||||||
Real-time voice, video, and data channels for Orama Network namespaces.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Client A Client B
|
|
||||||
│ │
|
|
||||||
│ 1. Get TURN credentials (REST) │
|
|
||||||
│ 2. Connect WebSocket (signaling) │
|
|
||||||
│ 3. Exchange SDP/ICE via SFU │
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
┌──────────┐ UDP relay ┌──────────┐
|
|
||||||
│ TURN │◄──────────────────►│ TURN │
|
|
||||||
│ Server │ (public IPs) │ Server │
|
|
||||||
│ Node 1 │ │ Node 2 │
|
|
||||||
└────┬─────┘ └────┬─────┘
|
|
||||||
│ WireGuard │ WireGuard
|
|
||||||
▼ ▼
|
|
||||||
┌──────────────────────────────────────────┐
|
|
||||||
│ SFU Servers (3 nodes) │
|
|
||||||
│ - WebSocket signaling (WireGuard only) │
|
|
||||||
│ - Pion WebRTC (RTP forwarding) │
|
|
||||||
│ - Room management │
|
|
||||||
│ - Track publish/subscribe │
|
|
||||||
└──────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key design decisions:**
|
|
||||||
- **TURN-shielded**: SFU binds only to WireGuard IPs. All client media flows through TURN relay.
|
|
||||||
- **`iceTransportPolicy: relay`** enforced server-side — no direct peer connections.
|
|
||||||
- **Opt-in per namespace** via `orama namespace enable webrtc`.
|
|
||||||
- **SFU on all 3 nodes**, **TURN on 2 of 3 nodes** (redundancy without over-provisioning).
|
|
||||||
- **Separate port allocation** from existing namespace services.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Namespace must be provisioned with a ready cluster (RQLite + Olric + Gateway running).
|
|
||||||
- Command must be run on a cluster node (uses internal gateway endpoint).
|
|
||||||
|
|
||||||
## Enable / Disable
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable WebRTC for a namespace
|
|
||||||
orama namespace enable webrtc --namespace myapp
|
|
||||||
|
|
||||||
# Check status
|
|
||||||
orama namespace webrtc-status --namespace myapp
|
|
||||||
|
|
||||||
# Disable WebRTC (stops services, deallocates ports, removes DNS)
|
|
||||||
orama namespace disable webrtc --namespace myapp
|
|
||||||
```
|
|
||||||
|
|
||||||
### What happens on enable:
|
|
||||||
1. Generates a per-namespace TURN shared secret (32 bytes, crypto/rand)
|
|
||||||
2. Inserts `namespace_webrtc_config` DB record
|
|
||||||
3. Allocates WebRTC port blocks on each node (SFU signaling + media range, TURN relay range)
|
|
||||||
4. Spawns TURN on 2 nodes (selected by capacity)
|
|
||||||
5. Spawns SFU on all 3 nodes
|
|
||||||
6. Creates DNS A records: `turn.ns-{name}.{baseDomain}` pointing to TURN node public IPs
|
|
||||||
7. Updates cluster state on all nodes (for cold-boot restoration)
|
|
||||||
|
|
||||||
### What happens on disable:
|
|
||||||
1. Stops SFU on all 3 nodes
|
|
||||||
2. Stops TURN on 2 nodes
|
|
||||||
3. Deallocates all WebRTC ports
|
|
||||||
4. Deletes TURN DNS records
|
|
||||||
5. Cleans up DB records (`namespace_webrtc_config`, `webrtc_rooms`)
|
|
||||||
6. Updates cluster state
|
|
||||||
|
|
||||||
## Client Integration (JavaScript)
|
|
||||||
|
|
||||||
### Authentication
|
|
||||||
|
|
||||||
All WebRTC endpoints require authentication. Use one of:
|
|
||||||
|
|
||||||
```
|
|
||||||
# Option A: API Key via header (recommended)
|
|
||||||
X-API-Key: <your-namespace-api-key>
|
|
||||||
|
|
||||||
# Option B: API Key via Authorization header
|
|
||||||
Authorization: ApiKey <your-namespace-api-key>
|
|
||||||
|
|
||||||
# Option C: JWT Bearer token
|
|
||||||
Authorization: Bearer <jwt>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1. Get TURN Credentials
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const response = await fetch('https://ns-myapp.orama-devnet.network/v1/webrtc/turn/credentials', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'X-API-Key': apiKey }
|
|
||||||
});
|
|
||||||
|
|
||||||
const { uris, username, password, ttl } = await response.json();
|
|
||||||
// uris: [
|
|
||||||
// "turn:turn.ns-myapp.orama-devnet.network:3478?transport=udp",
|
|
||||||
// "turn:turn.ns-myapp.orama-devnet.network:3478?transport=tcp",
|
|
||||||
// "turns:turn.ns-myapp.orama-devnet.network:5349"
|
|
||||||
// ]
|
|
||||||
// username: "{expiry_unix}:{namespace}"
|
|
||||||
// password: HMAC-SHA1 derived (base64)
|
|
||||||
// ttl: 600 (seconds)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Create PeerConnection
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const pc = new RTCPeerConnection({
|
|
||||||
iceServers: [{ urls: uris, username, credential: password }],
|
|
||||||
iceTransportPolicy: 'relay' // enforced by SFU
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Connect Signaling WebSocket
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const ws = new WebSocket(
|
|
||||||
`wss://ns-myapp.orama-devnet.network/v1/webrtc/signal?room=${roomId}&api_key=${apiKey}`
|
|
||||||
);
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
switch (msg.type) {
|
|
||||||
case 'offer': handleOffer(msg); break;
|
|
||||||
case 'answer': handleAnswer(msg); break;
|
|
||||||
case 'ice-candidate': handleICE(msg); break;
|
|
||||||
case 'peer-joined': handleJoin(msg); break;
|
|
||||||
case 'peer-left': handleLeave(msg); break;
|
|
||||||
case 'turn-credentials':
|
|
||||||
case 'refresh-credentials':
|
|
||||||
updateTURN(msg); // SFU sends refreshed creds at 80% TTL
|
|
||||||
break;
|
|
||||||
case 'server-draining':
|
|
||||||
reconnect(); // SFU shutting down, reconnect to another node
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Room Management (REST)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const headers = { 'X-API-Key': apiKey, 'Content-Type': 'application/json' };
|
|
||||||
|
|
||||||
// Create room
|
|
||||||
await fetch('/v1/webrtc/rooms', {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ room_id: 'my-room' })
|
|
||||||
});
|
|
||||||
|
|
||||||
// List rooms
|
|
||||||
const rooms = await fetch('/v1/webrtc/rooms', { headers });
|
|
||||||
|
|
||||||
// Close room
|
|
||||||
await fetch('/v1/webrtc/rooms?room_id=my-room', {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Reference
|
|
||||||
|
|
||||||
### REST Endpoints
|
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
|
||||||
|--------|------|------|-------------|
|
|
||||||
| POST | `/v1/webrtc/turn/credentials` | JWT/API key | Get TURN relay credentials |
|
|
||||||
| GET/WS | `/v1/webrtc/signal` | JWT/API key | WebSocket signaling |
|
|
||||||
| GET | `/v1/webrtc/rooms` | JWT/API key | List rooms |
|
|
||||||
| POST | `/v1/webrtc/rooms` | JWT/API key (owner) | Create room |
|
|
||||||
| DELETE | `/v1/webrtc/rooms` | JWT/API key (owner) | Close room |
|
|
||||||
|
|
||||||
### Signaling Messages
|
|
||||||
|
|
||||||
| Type | Direction | Description |
|
|
||||||
|------|-----------|-------------|
|
|
||||||
| `join` | Client → SFU | Join room |
|
|
||||||
| `offer` | Client ↔ SFU | SDP offer |
|
|
||||||
| `answer` | Client ↔ SFU | SDP answer |
|
|
||||||
| `ice-candidate` | Client ↔ SFU | ICE candidate |
|
|
||||||
| `leave` | Client → SFU | Leave room |
|
|
||||||
| `peer-joined` | SFU → Client | New peer notification |
|
|
||||||
| `peer-left` | SFU → Client | Peer departure |
|
|
||||||
| `turn-credentials` | SFU → Client | Initial TURN credentials |
|
|
||||||
| `refresh-credentials` | SFU → Client | Refreshed credentials (at 80% TTL) |
|
|
||||||
| `server-draining` | SFU → Client | SFU shutting down |
|
|
||||||
|
|
||||||
## Port Allocation
|
|
||||||
|
|
||||||
WebRTC uses a **separate port allocation system** from the core namespace ports:
|
|
||||||
|
|
||||||
| Service | Port Range | Protocol | Per Namespace |
|
|
||||||
|---------|-----------|----------|---------------|
|
|
||||||
| SFU signaling | 30000-30099 | TCP (WireGuard only) | 1 port |
|
|
||||||
| SFU media (RTP) | 20000-29999 | UDP (WireGuard only) | 500 ports |
|
|
||||||
| TURN listen | 3478 | UDP + TCP | fixed |
|
|
||||||
| TURNS (TLS) | 5349 | TCP | fixed |
|
|
||||||
| TURN relay | 49152-65535 | UDP | 800 ports |
|
|
||||||
|
|
||||||
## TURN Credential Protocol
|
|
||||||
|
|
||||||
- Credentials use HMAC-SHA1 with a per-namespace shared secret
|
|
||||||
- Username format: `{expiry_unix}:{namespace}`
|
|
||||||
- Password: `base64(HMAC-SHA1(shared_secret, username))`
|
|
||||||
- Default TTL: 600 seconds (10 minutes)
|
|
||||||
- SFU proactively sends `refresh-credentials` at 80% of TTL (8 minutes)
|
|
||||||
- Clients should update ICE servers on receiving refresh
|
|
||||||
|
|
||||||
## TURNS TLS Certificate
|
|
||||||
|
|
||||||
TURNS (port 5349) uses TLS. Certificate provisioning:
|
|
||||||
|
|
||||||
1. **Let's Encrypt (primary)**: On TURN spawn, the TURN domain is added to the local Caddy instance's Caddyfile. Caddy provisions a Let's Encrypt cert via DNS-01 ACME challenge (using the orama DNS provider). TURN reads the cert from Caddy's storage.
|
|
||||||
2. **Self-signed (fallback)**: If Caddy cert provisioning fails (timeout, Caddy not running), a self-signed cert is generated with the node's public IP as SAN.
|
|
||||||
|
|
||||||
Caddy auto-renews Let's Encrypt certs at ~60 days. TURN picks up renewed certs on restart.
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check WebRTC status
|
|
||||||
orama namespace webrtc-status --namespace myapp
|
|
||||||
|
|
||||||
# Monitor report includes SFU/TURN status
|
|
||||||
orama monitor report --env devnet
|
|
||||||
|
|
||||||
# Inspector checks WebRTC health
|
|
||||||
orama inspector --env devnet
|
|
||||||
```
|
|
||||||
|
|
||||||
The monitoring report includes per-namespace `sfu_up` and `turn_up` fields. The inspector runs cross-node checks to verify SFU coverage (3 nodes) and TURN redundancy (2 nodes).
|
|
||||||
|
|
||||||
## Debugging
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# SFU logs
|
|
||||||
journalctl -u orama-namespace-sfu@myapp -f
|
|
||||||
|
|
||||||
# TURN logs
|
|
||||||
journalctl -u orama-namespace-turn@myapp -f
|
|
||||||
|
|
||||||
# Check service status
|
|
||||||
systemctl status orama-namespace-sfu@myapp
|
|
||||||
systemctl status orama-namespace-turn@myapp
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Model
|
|
||||||
|
|
||||||
- **Forced relay**: `iceTransportPolicy: relay` enforced server-side. Clients cannot bypass TURN.
|
|
||||||
- **HMAC credentials**: Per-namespace TURN shared secret. Credentials expire after 10 minutes.
|
|
||||||
- **Namespace isolation**: Each namespace has its own TURN secret, port ranges, and rooms.
|
|
||||||
- **Authentication required**: All WebRTC endpoints require API key or JWT (`X-API-Key` header, `Authorization: ApiKey`, or `Authorization: Bearer`).
|
|
||||||
- **Room management**: Creating/closing rooms requires namespace ownership.
|
|
||||||
- **SFU on WireGuard only**: SFU binds to 10.0.0.x, never 0.0.0.0. Only reachable via TURN relay.
|
|
||||||
- **Permissions-Policy**: `camera=(self), microphone=(self)` — only same-origin can access media devices.
|
|
||||||
|
|
||||||
## Firewall
|
|
||||||
|
|
||||||
When WebRTC is enabled, the following ports are opened via UFW on TURN nodes:
|
|
||||||
|
|
||||||
| Port | Protocol | Purpose |
|
|
||||||
|------|----------|---------|
|
|
||||||
| 3478 | UDP | TURN standard |
|
|
||||||
| 3478 | TCP | TURN TCP fallback (for clients behind UDP-blocking firewalls) |
|
|
||||||
| 5349 | TCP | TURNS — TURN over TLS (encrypted, works through strict firewalls/DPI) |
|
|
||||||
| 49152-65535 | UDP | TURN relay range (allocated per namespace) |
|
|
||||||
|
|
||||||
SFU ports are NOT opened in the firewall — they are WireGuard-internal only.
|
|
||||||
|
|
||||||
## Database Tables
|
|
||||||
|
|
||||||
| Table | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `namespace_webrtc_config` | Per-namespace WebRTC config (enabled, TURN secret, node counts) |
|
|
||||||
| `webrtc_rooms` | Room-to-SFU-node affinity |
|
|
||||||
| `webrtc_port_allocations` | SFU/TURN port tracking |
|
|
||||||
|
|
||||||
## Cold Boot Recovery
|
|
||||||
|
|
||||||
On node restart, the cluster state file (`cluster_state.json`) includes `has_sfu`, `has_turn`, and port allocation data. The restore process:
|
|
||||||
|
|
||||||
1. Core services restore first: RQLite → Olric → Gateway
|
|
||||||
2. If `has_turn` is set: fetches TURN shared secret from DB, spawns TURN
|
|
||||||
3. If `has_sfu` is set: fetches WebRTC config from DB, spawns SFU with TURN server list
|
|
||||||
|
|
||||||
If the DB is unavailable during restore, SFU/TURN restoration is skipped with a warning log. They will be restored on the next successful DB connection.
|
|
||||||
@ -1,241 +0,0 @@
|
|||||||
//go:build e2e
|
|
||||||
|
|
||||||
package shared_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
e2e "github.com/DeBrosOfficial/network/e2e"
|
|
||||||
)
|
|
||||||
|
|
||||||
// turnCredentialsResponse is the expected response from the TURN credentials endpoint.
|
|
||||||
type turnCredentialsResponse struct {
|
|
||||||
URLs []string `json:"urls"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
Credential string `json:"credential"`
|
|
||||||
TTL int `json:"ttl"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebRTC_TURNCredentials_RequiresAuth verifies that the TURN credentials endpoint
|
|
||||||
// rejects unauthenticated requests.
|
|
||||||
func TestWebRTC_TURNCredentials_RequiresAuth(t *testing.T) {
|
|
||||||
e2e.SkipIfMissingGateway(t)
|
|
||||||
|
|
||||||
gatewayURL := e2e.GetGatewayURL()
|
|
||||||
client := e2e.NewHTTPClient(10 * time.Second)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", gatewayURL+"/v1/webrtc/turn/credentials", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("expected 401 Unauthorized, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebRTC_TURNCredentials_ValidResponse verifies that authenticated requests to the
|
|
||||||
// TURN credentials endpoint return a valid credential structure.
|
|
||||||
func TestWebRTC_TURNCredentials_ValidResponse(t *testing.T) {
|
|
||||||
e2e.SkipIfMissingGateway(t)
|
|
||||||
|
|
||||||
gatewayURL := e2e.GetGatewayURL()
|
|
||||||
apiKey := e2e.GetAPIKey()
|
|
||||||
if apiKey == "" {
|
|
||||||
t.Skip("no API key configured")
|
|
||||||
}
|
|
||||||
client := e2e.NewHTTPClient(10 * time.Second)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", gatewayURL+"/v1/webrtc/turn/credentials", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create request: %v", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 OK, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var creds turnCredentialsResponse
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&creds); err != nil {
|
|
||||||
t.Fatalf("failed to decode response: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(creds.URLs) == 0 {
|
|
||||||
t.Fatal("expected at least one TURN URL")
|
|
||||||
}
|
|
||||||
if creds.Username == "" {
|
|
||||||
t.Fatal("expected non-empty username")
|
|
||||||
}
|
|
||||||
if creds.Credential == "" {
|
|
||||||
t.Fatal("expected non-empty credential")
|
|
||||||
}
|
|
||||||
if creds.TTL <= 0 {
|
|
||||||
t.Fatalf("expected positive TTL, got %d", creds.TTL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebRTC_Rooms_RequiresAuth verifies that the rooms endpoint rejects unauthenticated requests.
|
|
||||||
func TestWebRTC_Rooms_RequiresAuth(t *testing.T) {
|
|
||||||
e2e.SkipIfMissingGateway(t)
|
|
||||||
|
|
||||||
gatewayURL := e2e.GetGatewayURL()
|
|
||||||
client := e2e.NewHTTPClient(10 * time.Second)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", gatewayURL+"/v1/webrtc/rooms", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("expected 401 Unauthorized, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebRTC_Signal_RequiresAuth verifies that the signaling WebSocket rejects
|
|
||||||
// unauthenticated connections.
|
|
||||||
func TestWebRTC_Signal_RequiresAuth(t *testing.T) {
|
|
||||||
e2e.SkipIfMissingGateway(t)
|
|
||||||
|
|
||||||
gatewayURL := e2e.GetGatewayURL()
|
|
||||||
client := e2e.NewHTTPClient(10 * time.Second)
|
|
||||||
|
|
||||||
// Use regular HTTP GET to the signal endpoint — without auth it should return 401
|
|
||||||
// before WebSocket upgrade
|
|
||||||
req, err := http.NewRequest("GET", gatewayURL+"/v1/webrtc/signal?room=test-room", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create request: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebRTC_Rooms_CreateAndList verifies room creation and listing with proper auth.
|
|
||||||
func TestWebRTC_Rooms_CreateAndList(t *testing.T) {
|
|
||||||
e2e.SkipIfMissingGateway(t)
|
|
||||||
|
|
||||||
gatewayURL := e2e.GetGatewayURL()
|
|
||||||
apiKey := e2e.GetAPIKey()
|
|
||||||
if apiKey == "" {
|
|
||||||
t.Skip("no API key configured")
|
|
||||||
}
|
|
||||||
client := e2e.NewHTTPClient(10 * time.Second)
|
|
||||||
|
|
||||||
roomID := e2e.GenerateUniqueID("e2e-webrtc-room")
|
|
||||||
|
|
||||||
// Create room
|
|
||||||
createBody, _ := json.Marshal(map[string]string{"room_id": roomID})
|
|
||||||
req, err := http.NewRequest("POST", gatewayURL+"/v1/webrtc/rooms", bytes.NewReader(createBody))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create request: %v", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create room failed: %v", err)
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
||||||
t.Fatalf("expected 200/201, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List rooms
|
|
||||||
req, err = http.NewRequest("GET", gatewayURL+"/v1/webrtc/rooms", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create request: %v", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
|
|
||||||
resp, err = client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list rooms failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up: delete room
|
|
||||||
req, err = http.NewRequest("DELETE", gatewayURL+"/v1/webrtc/rooms?room_id="+roomID, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create request: %v", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
|
|
||||||
resp2, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("delete room failed: %v", err)
|
|
||||||
}
|
|
||||||
resp2.Body.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebRTC_PermissionsPolicy verifies the Permissions-Policy header allows camera and microphone.
|
|
||||||
func TestWebRTC_PermissionsPolicy(t *testing.T) {
|
|
||||||
e2e.SkipIfMissingGateway(t)
|
|
||||||
|
|
||||||
gatewayURL := e2e.GetGatewayURL()
|
|
||||||
apiKey := e2e.GetAPIKey()
|
|
||||||
if apiKey == "" {
|
|
||||||
t.Skip("no API key configured")
|
|
||||||
}
|
|
||||||
client := e2e.NewHTTPClient(10 * time.Second)
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", gatewayURL+"/v1/webrtc/rooms", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create request: %v", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("request failed: %v", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
pp := resp.Header.Get("Permissions-Policy")
|
|
||||||
if pp == "" {
|
|
||||||
t.Skip("Permissions-Policy header not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(pp, "camera=(self)") {
|
|
||||||
t.Errorf("Permissions-Policy missing camera=(self), got: %s", pp)
|
|
||||||
}
|
|
||||||
if !strings.Contains(pp, "microphone=(self)") {
|
|
||||||
t.Errorf("Permissions-Policy missing microphone=(self), got: %s", pp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
-- Migration 018: WebRTC Services (SFU + TURN) for Namespace Clusters
|
|
||||||
-- Adds per-namespace WebRTC configuration, room tracking, and port allocation
|
|
||||||
-- WebRTC is opt-in: enabled via `orama namespace enable webrtc`
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
-- Per-namespace WebRTC configuration
|
|
||||||
-- One row per namespace that has WebRTC enabled
|
|
||||||
CREATE TABLE IF NOT EXISTS namespace_webrtc_config (
|
|
||||||
id TEXT PRIMARY KEY, -- UUID
|
|
||||||
namespace_cluster_id TEXT NOT NULL UNIQUE, -- FK to namespace_clusters
|
|
||||||
namespace_name TEXT NOT NULL, -- Cached for easier lookups
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1, -- 1 = enabled, 0 = disabled
|
|
||||||
|
|
||||||
-- TURN authentication
|
|
||||||
turn_shared_secret TEXT NOT NULL, -- HMAC-SHA1 shared secret (base64, 32 bytes)
|
|
||||||
turn_credential_ttl INTEGER NOT NULL DEFAULT 600, -- Credential TTL in seconds (default: 10 min)
|
|
||||||
|
|
||||||
-- Service topology
|
|
||||||
sfu_node_count INTEGER NOT NULL DEFAULT 3, -- SFU instances (all 3 nodes)
|
|
||||||
turn_node_count INTEGER NOT NULL DEFAULT 2, -- TURN instances (2 of 3 nodes for HA)
|
|
||||||
|
|
||||||
-- Metadata
|
|
||||||
enabled_by TEXT NOT NULL, -- Wallet address that enabled WebRTC
|
|
||||||
enabled_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
disabled_at TIMESTAMP,
|
|
||||||
|
|
||||||
FOREIGN KEY (namespace_cluster_id) REFERENCES namespace_clusters(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webrtc_config_namespace ON namespace_webrtc_config(namespace_name);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webrtc_config_cluster ON namespace_webrtc_config(namespace_cluster_id);
|
|
||||||
|
|
||||||
-- WebRTC room tracking
|
|
||||||
-- Tracks active rooms and their SFU node affinity
|
|
||||||
CREATE TABLE IF NOT EXISTS webrtc_rooms (
|
|
||||||
id TEXT PRIMARY KEY, -- UUID
|
|
||||||
namespace_cluster_id TEXT NOT NULL, -- FK to namespace_clusters
|
|
||||||
namespace_name TEXT NOT NULL, -- Cached for easier lookups
|
|
||||||
room_id TEXT NOT NULL, -- Application-defined room identifier
|
|
||||||
|
|
||||||
-- SFU affinity
|
|
||||||
sfu_node_id TEXT NOT NULL, -- Node hosting this room's SFU
|
|
||||||
sfu_internal_ip TEXT NOT NULL, -- WireGuard IP of SFU node
|
|
||||||
sfu_signaling_port INTEGER NOT NULL, -- SFU WebSocket signaling port
|
|
||||||
|
|
||||||
-- Room state
|
|
||||||
participant_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
max_participants INTEGER NOT NULL DEFAULT 100,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
last_activity TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
-- Prevent duplicate rooms within a namespace
|
|
||||||
UNIQUE(namespace_cluster_id, room_id),
|
|
||||||
FOREIGN KEY (namespace_cluster_id) REFERENCES namespace_clusters(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webrtc_rooms_namespace ON webrtc_rooms(namespace_name);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webrtc_rooms_node ON webrtc_rooms(sfu_node_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webrtc_rooms_activity ON webrtc_rooms(last_activity);
|
|
||||||
|
|
||||||
-- WebRTC port allocations
|
|
||||||
-- Separate from namespace_port_allocations to avoid breaking existing port blocks
|
|
||||||
-- Each namespace gets SFU + TURN ports on each node where those services run
|
|
||||||
CREATE TABLE IF NOT EXISTS webrtc_port_allocations (
|
|
||||||
id TEXT PRIMARY KEY, -- UUID
|
|
||||||
node_id TEXT NOT NULL, -- Physical node ID
|
|
||||||
namespace_cluster_id TEXT NOT NULL, -- FK to namespace_clusters
|
|
||||||
service_type TEXT NOT NULL, -- 'sfu' or 'turn'
|
|
||||||
|
|
||||||
-- SFU ports (when service_type = 'sfu')
|
|
||||||
sfu_signaling_port INTEGER, -- WebSocket signaling port
|
|
||||||
sfu_media_port_start INTEGER, -- Start of RTP media port range
|
|
||||||
sfu_media_port_end INTEGER, -- End of RTP media port range
|
|
||||||
|
|
||||||
-- TURN ports (when service_type = 'turn')
|
|
||||||
turn_listen_port INTEGER, -- TURN listener port (3478)
|
|
||||||
turn_tls_port INTEGER, -- TURN TLS port (443/UDP)
|
|
||||||
turn_relay_port_start INTEGER, -- Start of relay port range
|
|
||||||
turn_relay_port_end INTEGER, -- End of relay port range
|
|
||||||
|
|
||||||
allocated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
-- Prevent overlapping allocations
|
|
||||||
UNIQUE(node_id, namespace_cluster_id, service_type),
|
|
||||||
FOREIGN KEY (namespace_cluster_id) REFERENCES namespace_clusters(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webrtc_ports_node ON webrtc_port_allocations(node_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webrtc_ports_cluster ON webrtc_port_allocations(namespace_cluster_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_webrtc_ports_type ON webrtc_port_allocations(service_type);
|
|
||||||
|
|
||||||
-- Mark migration as applied
|
|
||||||
INSERT OR IGNORE INTO schema_migrations(version) VALUES (18);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
-- Invalidate all existing refresh tokens.
|
|
||||||
-- Tokens were stored in plaintext; the application now stores SHA-256 hashes.
|
|
||||||
-- Users will need to re-authenticate (tokens have 30-day expiry anyway).
|
|
||||||
UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE revoked_at IS NULL;
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
import "net"
|
|
||||||
|
|
||||||
// WireGuardSubnet is the internal WireGuard mesh CIDR.
|
|
||||||
const WireGuardSubnet = "10.0.0.0/24"
|
|
||||||
|
|
||||||
// IsWireGuardPeer checks whether remoteAddr (host:port format) originates
|
|
||||||
// from the WireGuard mesh subnet. This provides cryptographic peer
|
|
||||||
// authentication since WireGuard validates keys at the tunnel layer.
|
|
||||||
func IsWireGuardPeer(remoteAddr string) bool {
|
|
||||||
host, _, err := net.SplitHostPort(remoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
ip := net.ParseIP(host)
|
|
||||||
if ip == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, wgNet, _ := net.ParseCIDR(WireGuardSubnet)
|
|
||||||
return wgNet.Contains(ip)
|
|
||||||
}
|
|
||||||
@ -1,318 +0,0 @@
|
|||||||
package build
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"compress/gzip"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Manifest describes the contents of a binary archive.
|
|
||||||
type Manifest struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
Commit string `json:"commit"`
|
|
||||||
Date string `json:"date"`
|
|
||||||
Arch string `json:"arch"`
|
|
||||||
Checksums map[string]string `json:"checksums"` // filename -> sha256
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateManifest creates the manifest with SHA256 checksums of all binaries.
|
|
||||||
func (b *Builder) generateManifest() (*Manifest, error) {
|
|
||||||
m := &Manifest{
|
|
||||||
Version: b.version,
|
|
||||||
Commit: b.commit,
|
|
||||||
Date: b.date,
|
|
||||||
Arch: b.flags.Arch,
|
|
||||||
Checksums: make(map[string]string),
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(b.binDir)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
path := filepath.Join(b.binDir, entry.Name())
|
|
||||||
hash, err := sha256File(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to hash %s: %w", entry.Name(), err)
|
|
||||||
}
|
|
||||||
m.Checksums[entry.Name()] = hash
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createArchive creates the tar.gz archive from the build directory.
|
|
||||||
func (b *Builder) createArchive(outputPath string, manifest *Manifest) error {
|
|
||||||
fmt.Printf("\nCreating archive: %s\n", outputPath)
|
|
||||||
|
|
||||||
// Write manifest.json to tmpDir
|
|
||||||
manifestData, err := json.MarshalIndent(manifest, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(b.tmpDir, "manifest.json"), manifestData, 0644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create output file
|
|
||||||
f, err := os.Create(outputPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
gw := gzip.NewWriter(f)
|
|
||||||
defer gw.Close()
|
|
||||||
|
|
||||||
tw := tar.NewWriter(gw)
|
|
||||||
defer tw.Close()
|
|
||||||
|
|
||||||
// Add bin/ directory
|
|
||||||
if err := addDirToTar(tw, b.binDir, "bin"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add systemd/ directory
|
|
||||||
systemdDir := filepath.Join(b.tmpDir, "systemd")
|
|
||||||
if _, err := os.Stat(systemdDir); err == nil {
|
|
||||||
if err := addDirToTar(tw, systemdDir, "systemd"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add packages/ directory if it exists
|
|
||||||
packagesDir := filepath.Join(b.tmpDir, "packages")
|
|
||||||
if _, err := os.Stat(packagesDir); err == nil {
|
|
||||||
if err := addDirToTar(tw, packagesDir, "packages"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add manifest.json
|
|
||||||
if err := addFileToTar(tw, filepath.Join(b.tmpDir, "manifest.json"), "manifest.json"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add manifest.sig if it exists (created by --sign)
|
|
||||||
sigPath := filepath.Join(b.tmpDir, "manifest.sig")
|
|
||||||
if _, err := os.Stat(sigPath); err == nil {
|
|
||||||
if err := addFileToTar(tw, sigPath, "manifest.sig"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print summary
|
|
||||||
fmt.Printf(" bin/: %d binaries\n", len(manifest.Checksums))
|
|
||||||
fmt.Printf(" systemd/: namespace templates\n")
|
|
||||||
fmt.Printf(" manifest: v%s (%s) linux/%s\n", manifest.Version, manifest.Commit, manifest.Arch)
|
|
||||||
|
|
||||||
info, err := f.Stat()
|
|
||||||
if err == nil {
|
|
||||||
fmt.Printf(" size: %s\n", formatBytes(info.Size()))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// signManifest signs the manifest hash using rootwallet CLI.
|
|
||||||
// Produces manifest.sig containing the hex-encoded EVM signature.
|
|
||||||
func (b *Builder) signManifest(manifest *Manifest) error {
|
|
||||||
fmt.Printf("\nSigning manifest with rootwallet...\n")
|
|
||||||
|
|
||||||
// Serialize manifest deterministically (compact JSON, sorted keys via json.Marshal)
|
|
||||||
manifestData, err := json.Marshal(manifest)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal manifest: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash the manifest JSON
|
|
||||||
hash := sha256.Sum256(manifestData)
|
|
||||||
hashHex := hex.EncodeToString(hash[:])
|
|
||||||
|
|
||||||
// Call rw sign <hash> --chain evm
|
|
||||||
cmd := exec.Command("rw", "sign", hashHex, "--chain", "evm")
|
|
||||||
var stdout, stderr strings.Builder
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("rw sign failed: %w\n%s", err, stderr.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
signature := strings.TrimSpace(stdout.String())
|
|
||||||
if signature == "" {
|
|
||||||
return fmt.Errorf("rw sign produced empty signature")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write signature file
|
|
||||||
sigPath := filepath.Join(b.tmpDir, "manifest.sig")
|
|
||||||
if err := os.WriteFile(sigPath, []byte(signature), 0644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write manifest.sig: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" Manifest signed (SHA256: %s...)\n", hashHex[:16])
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// addDirToTar adds all files in a directory to the tar archive under the given prefix.
|
|
||||||
func addDirToTar(tw *tar.Writer, srcDir, prefix string) error {
|
|
||||||
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate relative path
|
|
||||||
relPath, err := filepath.Rel(srcDir, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tarPath := filepath.Join(prefix, relPath)
|
|
||||||
|
|
||||||
if info.IsDir() {
|
|
||||||
header := &tar.Header{
|
|
||||||
Name: tarPath + "/",
|
|
||||||
Mode: 0755,
|
|
||||||
Typeflag: tar.TypeDir,
|
|
||||||
}
|
|
||||||
return tw.WriteHeader(header)
|
|
||||||
}
|
|
||||||
|
|
||||||
return addFileToTar(tw, path, tarPath)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// addFileToTar adds a single file to the tar archive.
|
|
||||||
func addFileToTar(tw *tar.Writer, srcPath, tarPath string) error {
|
|
||||||
f, err := os.Open(srcPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
info, err := f.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
header := &tar.Header{
|
|
||||||
Name: tarPath,
|
|
||||||
Size: info.Size(),
|
|
||||||
Mode: int64(info.Mode()),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tw.WriteHeader(header); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(tw, f)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// sha256File computes the SHA256 hash of a file.
|
|
||||||
func sha256File(path string) (string, error) {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
h := sha256.New()
|
|
||||||
if _, err := io.Copy(h, f); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(h.Sum(nil)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadFile downloads a URL to a local file path.
|
|
||||||
func downloadFile(url, destPath string) error {
|
|
||||||
client := &http.Client{Timeout: 5 * time.Minute}
|
|
||||||
resp, err := client.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to download %s: %w", url, err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("download %s returned status %d", url, resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.Create(destPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(f, resp.Body)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractFileFromTarball extracts a single file from a tar.gz archive.
|
|
||||||
func extractFileFromTarball(tarPath, targetFile, destPath string) error {
|
|
||||||
f, err := os.Open(tarPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
gr, err := gzip.NewReader(f)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer gr.Close()
|
|
||||||
|
|
||||||
tr := tar.NewReader(gr)
|
|
||||||
for {
|
|
||||||
header, err := tr.Next()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match the target file (strip leading ./ if present)
|
|
||||||
name := strings.TrimPrefix(header.Name, "./")
|
|
||||||
if name == targetFile {
|
|
||||||
out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
if _, err := io.Copy(out, tr); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("file %s not found in archive %s", targetFile, tarPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatBytes formats bytes into a human-readable string.
|
|
||||||
func formatBytes(b int64) string {
|
|
||||||
const unit = 1024
|
|
||||||
if b < unit {
|
|
||||||
return fmt.Sprintf("%d B", b)
|
|
||||||
}
|
|
||||||
div, exp := int64(unit), 0
|
|
||||||
for n := b / unit; n >= unit; n /= unit {
|
|
||||||
div *= unit
|
|
||||||
exp++
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
|
||||||
}
|
|
||||||
@ -1,829 +0,0 @@
|
|||||||
package build
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/constants"
|
|
||||||
)
|
|
||||||
|
|
||||||
// oramaBinary defines a binary to cross-compile from the project source.
|
|
||||||
type oramaBinary struct {
|
|
||||||
Name string // output binary name
|
|
||||||
Package string // Go package path relative to project root
|
|
||||||
// Extra ldflags beyond the standard ones
|
|
||||||
ExtraLDFlags string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Builder orchestrates the entire build process.
|
|
||||||
type Builder struct {
|
|
||||||
flags *Flags
|
|
||||||
projectDir string
|
|
||||||
tmpDir string
|
|
||||||
binDir string
|
|
||||||
version string
|
|
||||||
commit string
|
|
||||||
date string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBuilder creates a new Builder.
|
|
||||||
func NewBuilder(flags *Flags) *Builder {
|
|
||||||
return &Builder{flags: flags}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build runs the full build pipeline.
|
|
||||||
func (b *Builder) Build() error {
|
|
||||||
start := time.Now()
|
|
||||||
|
|
||||||
// Find project root
|
|
||||||
projectDir, err := findProjectRoot()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
b.projectDir = projectDir
|
|
||||||
|
|
||||||
// Read version from Makefile or use "dev"
|
|
||||||
b.version = b.readVersion()
|
|
||||||
b.commit = b.readCommit()
|
|
||||||
b.date = time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
|
||||||
|
|
||||||
// Create temp build directory
|
|
||||||
b.tmpDir, err = os.MkdirTemp("", "orama-build-*")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create temp dir: %w", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(b.tmpDir)
|
|
||||||
|
|
||||||
b.binDir = filepath.Join(b.tmpDir, "bin")
|
|
||||||
if err := os.MkdirAll(b.binDir, 0755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create bin dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Building orama %s for linux/%s\n", b.version, b.flags.Arch)
|
|
||||||
fmt.Printf("Project: %s\n\n", b.projectDir)
|
|
||||||
|
|
||||||
// Step 1: Cross-compile Orama binaries
|
|
||||||
if err := b.buildOramaBinaries(); err != nil {
|
|
||||||
return fmt.Errorf("failed to build orama binaries: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Cross-compile Vault Guardian (Zig)
|
|
||||||
if err := b.buildVaultGuardian(); err != nil {
|
|
||||||
return fmt.Errorf("failed to build vault-guardian: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Cross-compile Olric
|
|
||||||
if err := b.buildOlric(); err != nil {
|
|
||||||
return fmt.Errorf("failed to build olric: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Cross-compile IPFS Cluster
|
|
||||||
if err := b.buildIPFSCluster(); err != nil {
|
|
||||||
return fmt.Errorf("failed to build ipfs-cluster: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Build CoreDNS with RQLite plugin
|
|
||||||
if err := b.buildCoreDNS(); err != nil {
|
|
||||||
return fmt.Errorf("failed to build coredns: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Build Caddy with Orama DNS module
|
|
||||||
if err := b.buildCaddy(); err != nil {
|
|
||||||
return fmt.Errorf("failed to build caddy: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 7: Download pre-built IPFS Kubo
|
|
||||||
if err := b.downloadIPFS(); err != nil {
|
|
||||||
return fmt.Errorf("failed to download ipfs: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 8: Download pre-built RQLite
|
|
||||||
if err := b.downloadRQLite(); err != nil {
|
|
||||||
return fmt.Errorf("failed to download rqlite: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 9: Copy systemd templates
|
|
||||||
if err := b.copySystemdTemplates(); err != nil {
|
|
||||||
return fmt.Errorf("failed to copy systemd templates: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 10: Generate manifest
|
|
||||||
manifest, err := b.generateManifest()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to generate manifest: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 11: Sign manifest (optional)
|
|
||||||
if b.flags.Sign {
|
|
||||||
if err := b.signManifest(manifest); err != nil {
|
|
||||||
return fmt.Errorf("failed to sign manifest: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 12: Create archive
|
|
||||||
outputPath := b.flags.Output
|
|
||||||
if outputPath == "" {
|
|
||||||
outputPath = fmt.Sprintf("/tmp/orama-%s-linux-%s.tar.gz", b.version, b.flags.Arch)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := b.createArchive(outputPath, manifest); err != nil {
|
|
||||||
return fmt.Errorf("failed to create archive: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
elapsed := time.Since(start).Round(time.Second)
|
|
||||||
fmt.Printf("\nBuild complete in %s\n", elapsed)
|
|
||||||
fmt.Printf("Archive: %s\n", outputPath)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Builder) buildOramaBinaries() error {
|
|
||||||
fmt.Println("[1/8] Cross-compiling Orama binaries...")
|
|
||||||
|
|
||||||
ldflags := fmt.Sprintf("-s -w -X 'main.version=%s' -X 'main.commit=%s' -X 'main.date=%s'",
|
|
||||||
b.version, b.commit, b.date)
|
|
||||||
|
|
||||||
gatewayLDFlags := fmt.Sprintf("%s -X 'github.com/DeBrosOfficial/network/pkg/gateway.BuildVersion=%s' -X 'github.com/DeBrosOfficial/network/pkg/gateway.BuildCommit=%s' -X 'github.com/DeBrosOfficial/network/pkg/gateway.BuildTime=%s'",
|
|
||||||
ldflags, b.version, b.commit, b.date)
|
|
||||||
|
|
||||||
binaries := []oramaBinary{
|
|
||||||
{Name: "orama", Package: "./cmd/cli/"},
|
|
||||||
{Name: "orama-node", Package: "./cmd/node/"},
|
|
||||||
{Name: "gateway", Package: "./cmd/gateway/", ExtraLDFlags: gatewayLDFlags},
|
|
||||||
{Name: "identity", Package: "./cmd/identity/"},
|
|
||||||
{Name: "sfu", Package: "./cmd/sfu/"},
|
|
||||||
{Name: "turn", Package: "./cmd/turn/"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, bin := range binaries {
|
|
||||||
flags := ldflags
|
|
||||||
if bin.ExtraLDFlags != "" {
|
|
||||||
flags = bin.ExtraLDFlags
|
|
||||||
}
|
|
||||||
|
|
||||||
output := filepath.Join(b.binDir, bin.Name)
|
|
||||||
cmd := exec.Command("go", "build",
|
|
||||||
"-ldflags", flags,
|
|
||||||
"-trimpath",
|
|
||||||
"-o", output,
|
|
||||||
bin.Package)
|
|
||||||
cmd.Dir = b.projectDir
|
|
||||||
cmd.Env = b.crossEnv()
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
|
|
||||||
if b.flags.Verbose {
|
|
||||||
fmt.Printf(" go build -o %s %s\n", bin.Name, bin.Package)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to build %s: %w", bin.Name, err)
|
|
||||||
}
|
|
||||||
fmt.Printf(" ✓ %s\n", bin.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Builder) buildVaultGuardian() error {
|
|
||||||
fmt.Println("[2/8] Cross-compiling Vault Guardian (Zig)...")
|
|
||||||
|
|
||||||
// Ensure zig is available
|
|
||||||
if _, err := exec.LookPath("zig"); err != nil {
|
|
||||||
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")
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map Go arch to Zig target triple
|
|
||||||
var zigTarget string
|
|
||||||
switch b.flags.Arch {
|
|
||||||
case "amd64":
|
|
||||||
zigTarget = "x86_64-linux-musl"
|
|
||||||
case "arm64":
|
|
||||||
zigTarget = "aarch64-linux-musl"
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported architecture for vault: %s", b.flags.Arch)
|
|
||||||
}
|
|
||||||
|
|
||||||
if b.flags.Verbose {
|
|
||||||
fmt.Printf(" zig build -Dtarget=%s -Doptimize=ReleaseSafe\n", zigTarget)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command("zig", "build",
|
|
||||||
fmt.Sprintf("-Dtarget=%s", zigTarget),
|
|
||||||
"-Doptimize=ReleaseSafe")
|
|
||||||
cmd.Dir = vaultDir
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("zig build failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy output binary to build bin dir
|
|
||||||
src := filepath.Join(vaultDir, "zig-out", "bin", "vault-guardian")
|
|
||||||
dst := filepath.Join(b.binDir, "vault-guardian")
|
|
||||||
if err := copyFile(src, dst); err != nil {
|
|
||||||
return fmt.Errorf("failed to copy vault-guardian binary: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(" ✓ vault-guardian")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyFile copies a file from src to dst, preserving executable permissions.
|
|
||||||
func copyFile(src, dst string) error {
|
|
||||||
srcFile, err := os.Open(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer srcFile.Close()
|
|
||||||
|
|
||||||
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer dstFile.Close()
|
|
||||||
|
|
||||||
if _, err := srcFile.WriteTo(dstFile); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Builder) buildOlric() error {
|
|
||||||
fmt.Printf("[3/8] Cross-compiling Olric %s...\n", constants.OlricVersion)
|
|
||||||
|
|
||||||
// go install doesn't support cross-compilation with GOBIN set,
|
|
||||||
// so we create a temporary module and use go build -o instead.
|
|
||||||
tmpDir, err := os.MkdirTemp("", "olric-build-*")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create temp dir: %w", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tmpDir)
|
|
||||||
|
|
||||||
modInit := exec.Command("go", "mod", "init", "olric-build")
|
|
||||||
modInit.Dir = tmpDir
|
|
||||||
modInit.Stderr = os.Stderr
|
|
||||||
if err := modInit.Run(); err != nil {
|
|
||||||
return fmt.Errorf("go mod init: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
modGet := exec.Command("go", "get",
|
|
||||||
fmt.Sprintf("github.com/olric-data/olric/cmd/olric-server@%s", constants.OlricVersion))
|
|
||||||
modGet.Dir = tmpDir
|
|
||||||
modGet.Env = append(os.Environ(),
|
|
||||||
"GOPROXY=https://proxy.golang.org|direct",
|
|
||||||
"GONOSUMDB=*")
|
|
||||||
modGet.Stderr = os.Stderr
|
|
||||||
if err := modGet.Run(); err != nil {
|
|
||||||
return fmt.Errorf("go get olric: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command("go", "build",
|
|
||||||
"-ldflags", "-s -w",
|
|
||||||
"-trimpath",
|
|
||||||
"-o", filepath.Join(b.binDir, "olric-server"),
|
|
||||||
fmt.Sprintf("github.com/olric-data/olric/cmd/olric-server"))
|
|
||||||
cmd.Dir = tmpDir
|
|
||||||
cmd.Env = append(b.crossEnv(),
|
|
||||||
"GOPROXY=https://proxy.golang.org|direct",
|
|
||||||
"GONOSUMDB=*")
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Println(" ✓ olric-server")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Builder) buildIPFSCluster() error {
|
|
||||||
fmt.Printf("[4/8] Cross-compiling IPFS Cluster %s...\n", constants.IPFSClusterVersion)
|
|
||||||
|
|
||||||
tmpDir, err := os.MkdirTemp("", "ipfs-cluster-build-*")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create temp dir: %w", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(tmpDir)
|
|
||||||
|
|
||||||
modInit := exec.Command("go", "mod", "init", "ipfs-cluster-build")
|
|
||||||
modInit.Dir = tmpDir
|
|
||||||
modInit.Stderr = os.Stderr
|
|
||||||
if err := modInit.Run(); err != nil {
|
|
||||||
return fmt.Errorf("go mod init: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
modGet := exec.Command("go", "get",
|
|
||||||
fmt.Sprintf("github.com/ipfs-cluster/ipfs-cluster/cmd/ipfs-cluster-service@%s", constants.IPFSClusterVersion))
|
|
||||||
modGet.Dir = tmpDir
|
|
||||||
modGet.Env = append(os.Environ(),
|
|
||||||
"GOPROXY=https://proxy.golang.org|direct",
|
|
||||||
"GONOSUMDB=*")
|
|
||||||
modGet.Stderr = os.Stderr
|
|
||||||
if err := modGet.Run(); err != nil {
|
|
||||||
return fmt.Errorf("go get ipfs-cluster: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.Command("go", "build",
|
|
||||||
"-ldflags", "-s -w",
|
|
||||||
"-trimpath",
|
|
||||||
"-o", filepath.Join(b.binDir, "ipfs-cluster-service"),
|
|
||||||
"github.com/ipfs-cluster/ipfs-cluster/cmd/ipfs-cluster-service")
|
|
||||||
cmd.Dir = tmpDir
|
|
||||||
cmd.Env = append(b.crossEnv(),
|
|
||||||
"GOPROXY=https://proxy.golang.org|direct",
|
|
||||||
"GONOSUMDB=*")
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Println(" ✓ ipfs-cluster-service")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Builder) buildCoreDNS() error {
|
|
||||||
fmt.Printf("[5/8] Building CoreDNS %s with RQLite plugin...\n", constants.CoreDNSVersion)
|
|
||||||
|
|
||||||
buildDir := filepath.Join(b.tmpDir, "coredns-build")
|
|
||||||
|
|
||||||
// Clone CoreDNS
|
|
||||||
fmt.Println(" Cloning CoreDNS...")
|
|
||||||
cmd := exec.Command("git", "clone", "--depth", "1",
|
|
||||||
"--branch", "v"+constants.CoreDNSVersion,
|
|
||||||
"https://github.com/coredns/coredns.git", buildDir)
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to clone coredns: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy RQLite plugin from local source
|
|
||||||
pluginSrc := filepath.Join(b.projectDir, "pkg", "coredns", "rqlite")
|
|
||||||
pluginDst := filepath.Join(buildDir, "plugin", "rqlite")
|
|
||||||
if err := os.MkdirAll(pluginDst, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(pluginSrc)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read rqlite plugin source at %s: %w", pluginSrc, err)
|
|
||||||
}
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".go" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(filepath.Join(pluginSrc, entry.Name()))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(pluginDst, entry.Name()), data, 0644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write plugin.cfg (same as build-linux-coredns.sh)
|
|
||||||
pluginCfg := `metadata:metadata
|
|
||||||
cancel:cancel
|
|
||||||
tls:tls
|
|
||||||
reload:reload
|
|
||||||
nsid:nsid
|
|
||||||
bufsize:bufsize
|
|
||||||
root:root
|
|
||||||
bind:bind
|
|
||||||
debug:debug
|
|
||||||
trace:trace
|
|
||||||
ready:ready
|
|
||||||
health:health
|
|
||||||
pprof:pprof
|
|
||||||
prometheus:metrics
|
|
||||||
errors:errors
|
|
||||||
log:log
|
|
||||||
dnstap:dnstap
|
|
||||||
local:local
|
|
||||||
dns64:dns64
|
|
||||||
acl:acl
|
|
||||||
any:any
|
|
||||||
chaos:chaos
|
|
||||||
loadbalance:loadbalance
|
|
||||||
cache:cache
|
|
||||||
rewrite:rewrite
|
|
||||||
header:header
|
|
||||||
dnssec:dnssec
|
|
||||||
autopath:autopath
|
|
||||||
minimal:minimal
|
|
||||||
template:template
|
|
||||||
transfer:transfer
|
|
||||||
hosts:hosts
|
|
||||||
file:file
|
|
||||||
auto:auto
|
|
||||||
secondary:secondary
|
|
||||||
loop:loop
|
|
||||||
forward:forward
|
|
||||||
grpc:grpc
|
|
||||||
erratic:erratic
|
|
||||||
whoami:whoami
|
|
||||||
on:github.com/coredns/caddy/onevent
|
|
||||||
sign:sign
|
|
||||||
view:view
|
|
||||||
rqlite:rqlite
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(filepath.Join(buildDir, "plugin.cfg"), []byte(pluginCfg), 0644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add dependencies
|
|
||||||
fmt.Println(" Adding dependencies...")
|
|
||||||
goPath := os.Getenv("PATH")
|
|
||||||
baseEnv := append(os.Environ(),
|
|
||||||
"PATH="+goPath,
|
|
||||||
"GOPROXY=https://proxy.golang.org|direct",
|
|
||||||
"GONOSUMDB=*")
|
|
||||||
|
|
||||||
for _, dep := range []string{"github.com/miekg/dns@latest", "go.uber.org/zap@latest"} {
|
|
||||||
cmd := exec.Command("go", "get", dep)
|
|
||||||
cmd.Dir = buildDir
|
|
||||||
cmd.Env = baseEnv
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to get %s: %w", dep, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd = exec.Command("go", "mod", "tidy")
|
|
||||||
cmd.Dir = buildDir
|
|
||||||
cmd.Env = baseEnv
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("go mod tidy failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate plugin code
|
|
||||||
fmt.Println(" Generating plugin code...")
|
|
||||||
cmd = exec.Command("go", "generate")
|
|
||||||
cmd.Dir = buildDir
|
|
||||||
cmd.Env = baseEnv
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("go generate failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cross-compile
|
|
||||||
fmt.Println(" Building binary...")
|
|
||||||
cmd = exec.Command("go", "build",
|
|
||||||
"-ldflags", "-s -w",
|
|
||||||
"-trimpath",
|
|
||||||
"-o", filepath.Join(b.binDir, "coredns"))
|
|
||||||
cmd.Dir = buildDir
|
|
||||||
cmd.Env = append(baseEnv,
|
|
||||||
"GOOS=linux",
|
|
||||||
fmt.Sprintf("GOARCH=%s", b.flags.Arch),
|
|
||||||
"CGO_ENABLED=0")
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("build failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(" ✓ coredns")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Builder) buildCaddy() error {
|
|
||||||
fmt.Printf("[6/8] Building Caddy %s with Orama DNS module...\n", constants.CaddyVersion)
|
|
||||||
|
|
||||||
// Ensure xcaddy is available
|
|
||||||
if _, err := exec.LookPath("xcaddy"); err != nil {
|
|
||||||
return fmt.Errorf("xcaddy not found in PATH — install with: go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest")
|
|
||||||
}
|
|
||||||
|
|
||||||
moduleDir := filepath.Join(b.tmpDir, "caddy-dns-orama")
|
|
||||||
if err := os.MkdirAll(moduleDir, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write go.mod
|
|
||||||
goMod := fmt.Sprintf(`module github.com/DeBrosOfficial/caddy-dns-orama
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/caddyserver/caddy/v2 v2.%s
|
|
||||||
github.com/libdns/libdns v1.1.0
|
|
||||||
)
|
|
||||||
`, constants.CaddyVersion[2:])
|
|
||||||
if err := os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(goMod), 0644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write provider.go — read from the caddy installer's generated code
|
|
||||||
// We inline the same provider code used by the VPS-side caddy installer
|
|
||||||
providerCode := generateCaddyProviderCode()
|
|
||||||
if err := os.WriteFile(filepath.Join(moduleDir, "provider.go"), []byte(providerCode), 0644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// go mod tidy
|
|
||||||
cmd := exec.Command("go", "mod", "tidy")
|
|
||||||
cmd.Dir = moduleDir
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"GOPROXY=https://proxy.golang.org|direct",
|
|
||||||
"GONOSUMDB=*")
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("go mod tidy failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build with xcaddy
|
|
||||||
fmt.Println(" Building binary...")
|
|
||||||
cmd = exec.Command("xcaddy", "build",
|
|
||||||
"v"+constants.CaddyVersion,
|
|
||||||
"--with", "github.com/DeBrosOfficial/caddy-dns-orama="+moduleDir,
|
|
||||||
"--output", filepath.Join(b.binDir, "caddy"))
|
|
||||||
cmd.Env = append(os.Environ(),
|
|
||||||
"GOOS=linux",
|
|
||||||
fmt.Sprintf("GOARCH=%s", b.flags.Arch),
|
|
||||||
"GOPROXY=https://proxy.golang.org|direct",
|
|
||||||
"GONOSUMDB=*")
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("xcaddy build failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(" ✓ caddy")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Builder) downloadIPFS() error {
|
|
||||||
fmt.Printf("[7/8] Downloading IPFS Kubo %s...\n", constants.IPFSKuboVersion)
|
|
||||||
|
|
||||||
arch := b.flags.Arch
|
|
||||||
tarball := fmt.Sprintf("kubo_%s_linux-%s.tar.gz", constants.IPFSKuboVersion, arch)
|
|
||||||
url := fmt.Sprintf("https://dist.ipfs.tech/kubo/%s/%s", constants.IPFSKuboVersion, tarball)
|
|
||||||
tarPath := filepath.Join(b.tmpDir, tarball)
|
|
||||||
|
|
||||||
if err := downloadFile(url, tarPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract ipfs binary from kubo/ipfs
|
|
||||||
if err := extractFileFromTarball(tarPath, "kubo/ipfs", filepath.Join(b.binDir, "ipfs")); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(" ✓ ipfs")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Builder) downloadRQLite() error {
|
|
||||||
fmt.Printf("[8/8] Downloading RQLite %s...\n", constants.RQLiteVersion)
|
|
||||||
|
|
||||||
arch := b.flags.Arch
|
|
||||||
tarball := fmt.Sprintf("rqlite-v%s-linux-%s.tar.gz", constants.RQLiteVersion, arch)
|
|
||||||
url := fmt.Sprintf("https://github.com/rqlite/rqlite/releases/download/v%s/%s", constants.RQLiteVersion, tarball)
|
|
||||||
tarPath := filepath.Join(b.tmpDir, tarball)
|
|
||||||
|
|
||||||
if err := downloadFile(url, tarPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract rqlited binary
|
|
||||||
extractDir := fmt.Sprintf("rqlite-v%s-linux-%s", constants.RQLiteVersion, arch)
|
|
||||||
if err := extractFileFromTarball(tarPath, extractDir+"/rqlited", filepath.Join(b.binDir, "rqlited")); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(" ✓ rqlited")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Builder) copySystemdTemplates() error {
|
|
||||||
systemdSrc := filepath.Join(b.projectDir, "systemd")
|
|
||||||
systemdDst := filepath.Join(b.tmpDir, "systemd")
|
|
||||||
if err := os.MkdirAll(systemdDst, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(systemdSrc)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read systemd dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".service") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(filepath.Join(systemdSrc, entry.Name()))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(filepath.Join(systemdDst, entry.Name()), data, 0644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// crossEnv returns the environment for cross-compilation.
|
|
||||||
func (b *Builder) crossEnv() []string {
|
|
||||||
return append(os.Environ(),
|
|
||||||
"GOOS=linux",
|
|
||||||
fmt.Sprintf("GOARCH=%s", b.flags.Arch),
|
|
||||||
"CGO_ENABLED=0")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Builder) readVersion() string {
|
|
||||||
// Try to read from Makefile
|
|
||||||
data, err := os.ReadFile(filepath.Join(b.projectDir, "Makefile"))
|
|
||||||
if err != nil {
|
|
||||||
return "dev"
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if strings.HasPrefix(line, "VERSION") {
|
|
||||||
parts := strings.SplitN(line, ":=", 2)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
return strings.TrimSpace(parts[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "dev"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Builder) readCommit() string {
|
|
||||||
cmd := exec.Command("git", "rev-parse", "--short", "HEAD")
|
|
||||||
cmd.Dir = b.projectDir
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(out))
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateCaddyProviderCode returns the Caddy DNS provider Go source.
|
|
||||||
// This is the same code used by the VPS-side caddy installer.
|
|
||||||
func generateCaddyProviderCode() string {
|
|
||||||
return `// Package orama implements a DNS provider for Caddy that uses the Orama Network
|
|
||||||
// gateway's internal ACME API for DNS-01 challenge validation.
|
|
||||||
package orama
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/caddyserver/caddy/v2"
|
|
||||||
"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
|
|
||||||
"github.com/libdns/libdns"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
caddy.RegisterModule(Provider{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider wraps the Orama DNS provider for Caddy.
|
|
||||||
type Provider struct {
|
|
||||||
// Endpoint is the URL of the Orama gateway's ACME API
|
|
||||||
// Default: http://localhost:6001/v1/internal/acme
|
|
||||||
Endpoint string ` + "`json:\"endpoint,omitempty\"`" + `
|
|
||||||
}
|
|
||||||
|
|
||||||
// CaddyModule returns the Caddy module information.
|
|
||||||
func (Provider) CaddyModule() caddy.ModuleInfo {
|
|
||||||
return caddy.ModuleInfo{
|
|
||||||
ID: "dns.providers.orama",
|
|
||||||
New: func() caddy.Module { return new(Provider) },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provision sets up the module.
|
|
||||||
func (p *Provider) Provision(ctx caddy.Context) error {
|
|
||||||
if p.Endpoint == "" {
|
|
||||||
p.Endpoint = "http://localhost:6001/v1/internal/acme"
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalCaddyfile parses the Caddyfile configuration.
|
|
||||||
func (p *Provider) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
|
|
||||||
for d.Next() {
|
|
||||||
for d.NextBlock(0) {
|
|
||||||
switch d.Val() {
|
|
||||||
case "endpoint":
|
|
||||||
if !d.NextArg() {
|
|
||||||
return d.ArgErr()
|
|
||||||
}
|
|
||||||
p.Endpoint = d.Val()
|
|
||||||
default:
|
|
||||||
return d.Errf("unrecognized option: %s", d.Val())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppendRecords adds records to the zone.
|
|
||||||
func (p *Provider) AppendRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) {
|
|
||||||
var added []libdns.Record
|
|
||||||
for _, rec := range records {
|
|
||||||
rr := rec.RR()
|
|
||||||
if rr.Type != "TXT" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fqdn := rr.Name + "." + zone
|
|
||||||
payload := map[string]string{"fqdn": fqdn, "value": rr.Data}
|
|
||||||
body, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return added, fmt.Errorf("failed to marshal request: %w", err)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", p.Endpoint+"/present", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return added, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return added, fmt.Errorf("failed to present challenge: %w", err)
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return added, fmt.Errorf("present failed with status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
added = append(added, rec)
|
|
||||||
}
|
|
||||||
return added, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteRecords removes records from the zone.
|
|
||||||
func (p *Provider) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) {
|
|
||||||
var deleted []libdns.Record
|
|
||||||
for _, rec := range records {
|
|
||||||
rr := rec.RR()
|
|
||||||
if rr.Type != "TXT" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fqdn := rr.Name + "." + zone
|
|
||||||
payload := map[string]string{"fqdn": fqdn, "value": rr.Data}
|
|
||||||
body, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return deleted, fmt.Errorf("failed to marshal request: %w", err)
|
|
||||||
}
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", p.Endpoint+"/cleanup", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return deleted, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return deleted, fmt.Errorf("failed to cleanup challenge: %w", err)
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return deleted, fmt.Errorf("cleanup failed with status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
deleted = append(deleted, rec)
|
|
||||||
}
|
|
||||||
return deleted, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRecords returns the records in the zone. Not used for ACME.
|
|
||||||
func (p *Provider) GetRecords(ctx context.Context, zone string) ([]libdns.Record, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetRecords sets the records in the zone. Not used for ACME.
|
|
||||||
func (p *Provider) SetRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interface guards
|
|
||||||
var (
|
|
||||||
_ caddy.Module = (*Provider)(nil)
|
|
||||||
_ caddy.Provisioner = (*Provider)(nil)
|
|
||||||
_ caddyfile.Unmarshaler = (*Provider)(nil)
|
|
||||||
_ libdns.RecordAppender = (*Provider)(nil)
|
|
||||||
_ libdns.RecordDeleter = (*Provider)(nil)
|
|
||||||
_ libdns.RecordGetter = (*Provider)(nil)
|
|
||||||
_ libdns.RecordSetter = (*Provider)(nil)
|
|
||||||
)
|
|
||||||
`
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
package build
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Flags represents build command flags.
|
|
||||||
type Flags struct {
|
|
||||||
Arch string
|
|
||||||
Output string
|
|
||||||
Verbose bool
|
|
||||||
Sign bool // Sign the archive manifest with rootwallet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle is the entry point for the build command.
|
|
||||||
func Handle(args []string) {
|
|
||||||
flags, err := parseFlags(args)
|
|
||||||
if err != nil {
|
|
||||||
if err == flag.ErrHelp {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
b := NewBuilder(flags)
|
|
||||||
if err := b.Build(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFlags(args []string) (*Flags, error) {
|
|
||||||
fs := flag.NewFlagSet("build", flag.ContinueOnError)
|
|
||||||
fs.SetOutput(os.Stderr)
|
|
||||||
|
|
||||||
flags := &Flags{}
|
|
||||||
|
|
||||||
fs.StringVar(&flags.Arch, "arch", "amd64", "Target architecture (amd64, arm64)")
|
|
||||||
fs.StringVar(&flags.Output, "output", "", "Output archive path (default: /tmp/orama-<version>-linux-<arch>.tar.gz)")
|
|
||||||
fs.BoolVar(&flags.Verbose, "verbose", false, "Verbose output")
|
|
||||||
fs.BoolVar(&flags.Sign, "sign", false, "Sign the manifest with rootwallet (requires rw in PATH)")
|
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return flags, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// findProjectRoot walks up from the current directory looking for go.mod.
|
|
||||||
func findProjectRoot() (string, error) {
|
|
||||||
dir, err := os.Getwd()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
|
|
||||||
// Verify it's the network project
|
|
||||||
if _, err := os.Stat(filepath.Join(dir, "cmd", "cli")); err == nil {
|
|
||||||
return dir, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parent := filepath.Dir(dir)
|
|
||||||
if parent == dir {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
dir = parent
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("could not find project root (no go.mod with cmd/cli found)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectHostArch returns the host architecture in Go naming convention.
|
|
||||||
func detectHostArch() string {
|
|
||||||
return runtime.GOARCH
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package buildcmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/build"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cmd is the top-level build command.
|
|
||||||
var Cmd = &cobra.Command{
|
|
||||||
Use: "build",
|
|
||||||
Short: "Build pre-compiled binary archive for deployment",
|
|
||||||
Long: `Cross-compile all Orama binaries and dependencies for Linux,
|
|
||||||
then package them into a deployment archive. The archive includes:
|
|
||||||
- Orama binaries (CLI, node, gateway, identity, SFU, TURN)
|
|
||||||
- Olric, IPFS Kubo, IPFS Cluster, RQLite, CoreDNS, Caddy
|
|
||||||
- Systemd namespace templates
|
|
||||||
- manifest.json with checksums
|
|
||||||
|
|
||||||
The resulting archive can be pushed to nodes with 'orama node push'.`,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
build.Handle(args)
|
|
||||||
},
|
|
||||||
DisableFlagParsing: true,
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
package functioncmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/functions"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cmd is the top-level function command.
|
|
||||||
var Cmd = &cobra.Command{
|
|
||||||
Use: "function",
|
|
||||||
Short: "Manage serverless functions",
|
|
||||||
Long: `Deploy, invoke, and manage serverless functions on the Orama Network.
|
|
||||||
|
|
||||||
A function is a folder containing:
|
|
||||||
function.go — your handler code (uses the fn SDK)
|
|
||||||
function.yaml — configuration (name, memory, timeout, etc.)
|
|
||||||
|
|
||||||
Quick start:
|
|
||||||
orama function init my-function
|
|
||||||
cd my-function
|
|
||||||
orama function build
|
|
||||||
orama function deploy
|
|
||||||
orama function invoke my-function --data '{"name": "World"}'`,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
Cmd.AddCommand(functions.InitCmd)
|
|
||||||
Cmd.AddCommand(functions.BuildCmd)
|
|
||||||
Cmd.AddCommand(functions.DeployCmd)
|
|
||||||
Cmd.AddCommand(functions.InvokeCmd)
|
|
||||||
Cmd.AddCommand(functions.ListCmd)
|
|
||||||
Cmd.AddCommand(functions.GetCmd)
|
|
||||||
Cmd.AddCommand(functions.DeleteCmd)
|
|
||||||
Cmd.AddCommand(functions.LogsCmd)
|
|
||||||
Cmd.AddCommand(functions.VersionsCmd)
|
|
||||||
Cmd.AddCommand(functions.SecretsCmd)
|
|
||||||
Cmd.AddCommand(functions.TriggersCmd)
|
|
||||||
}
|
|
||||||
@ -1,200 +0,0 @@
|
|||||||
package monitorcmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor/display"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor/tui"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cmd is the root monitor command.
|
|
||||||
var Cmd = &cobra.Command{
|
|
||||||
Use: "monitor",
|
|
||||||
Short: "Monitor cluster health from your local machine",
|
|
||||||
Long: `SSH into cluster nodes and display real-time health data.
|
|
||||||
Runs 'orama node report --json' on each node and aggregates results.
|
|
||||||
|
|
||||||
Without a subcommand, launches the interactive TUI.`,
|
|
||||||
RunE: runLive,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared persistent flags.
|
|
||||||
var (
|
|
||||||
flagEnv string
|
|
||||||
flagJSON bool
|
|
||||||
flagNode string
|
|
||||||
flagConfig string
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
Cmd.PersistentFlags().StringVar(&flagEnv, "env", "", "Environment: devnet, testnet, mainnet (required)")
|
|
||||||
Cmd.PersistentFlags().BoolVar(&flagJSON, "json", false, "Machine-readable JSON output")
|
|
||||||
Cmd.PersistentFlags().StringVar(&flagNode, "node", "", "Filter to specific node host/IP")
|
|
||||||
Cmd.PersistentFlags().StringVar(&flagConfig, "config", "scripts/nodes.conf", "Path to nodes.conf")
|
|
||||||
Cmd.MarkPersistentFlagRequired("env")
|
|
||||||
|
|
||||||
Cmd.AddCommand(liveCmd)
|
|
||||||
Cmd.AddCommand(clusterCmd)
|
|
||||||
Cmd.AddCommand(nodeCmd)
|
|
||||||
Cmd.AddCommand(serviceCmd)
|
|
||||||
Cmd.AddCommand(meshCmd)
|
|
||||||
Cmd.AddCommand(dnsCmd)
|
|
||||||
Cmd.AddCommand(namespacesCmd)
|
|
||||||
Cmd.AddCommand(alertsCmd)
|
|
||||||
Cmd.AddCommand(reportCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Subcommands
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
var liveCmd = &cobra.Command{
|
|
||||||
Use: "live",
|
|
||||||
Short: "Interactive TUI monitor",
|
|
||||||
RunE: runLive,
|
|
||||||
}
|
|
||||||
|
|
||||||
var clusterCmd = &cobra.Command{
|
|
||||||
Use: "cluster",
|
|
||||||
Short: "Cluster overview (one-shot)",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
snap, err := collectSnapshot()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if flagJSON {
|
|
||||||
return display.ClusterJSON(snap, os.Stdout)
|
|
||||||
}
|
|
||||||
return display.ClusterTable(snap, os.Stdout)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var nodeCmd = &cobra.Command{
|
|
||||||
Use: "node",
|
|
||||||
Short: "Per-node health details (one-shot)",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
snap, err := collectSnapshot()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if flagJSON {
|
|
||||||
return display.NodeJSON(snap, os.Stdout)
|
|
||||||
}
|
|
||||||
return display.NodeTable(snap, os.Stdout)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var serviceCmd = &cobra.Command{
|
|
||||||
Use: "service",
|
|
||||||
Short: "Service status across the cluster (one-shot)",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
snap, err := collectSnapshot()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if flagJSON {
|
|
||||||
return display.ServiceJSON(snap, os.Stdout)
|
|
||||||
}
|
|
||||||
return display.ServiceTable(snap, os.Stdout)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var meshCmd = &cobra.Command{
|
|
||||||
Use: "mesh",
|
|
||||||
Short: "Mesh connectivity status (one-shot)",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
snap, err := collectSnapshot()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if flagJSON {
|
|
||||||
return display.MeshJSON(snap, os.Stdout)
|
|
||||||
}
|
|
||||||
return display.MeshTable(snap, os.Stdout)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var dnsCmd = &cobra.Command{
|
|
||||||
Use: "dns",
|
|
||||||
Short: "DNS health overview (one-shot)",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
snap, err := collectSnapshot()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if flagJSON {
|
|
||||||
return display.DNSJSON(snap, os.Stdout)
|
|
||||||
}
|
|
||||||
return display.DNSTable(snap, os.Stdout)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var namespacesCmd = &cobra.Command{
|
|
||||||
Use: "namespaces",
|
|
||||||
Short: "Namespace usage summary (one-shot)",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
snap, err := collectSnapshot()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if flagJSON {
|
|
||||||
return display.NamespacesJSON(snap, os.Stdout)
|
|
||||||
}
|
|
||||||
return display.NamespacesTable(snap, os.Stdout)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var alertsCmd = &cobra.Command{
|
|
||||||
Use: "alerts",
|
|
||||||
Short: "Active alerts and warnings (one-shot)",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
snap, err := collectSnapshot()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if flagJSON {
|
|
||||||
return display.AlertsJSON(snap, os.Stdout)
|
|
||||||
}
|
|
||||||
return display.AlertsTable(snap, os.Stdout)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var reportCmd = &cobra.Command{
|
|
||||||
Use: "report",
|
|
||||||
Short: "Full cluster report (JSON)",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
snap, err := collectSnapshot()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return display.FullReport(snap, os.Stdout)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func collectSnapshot() (*monitor.ClusterSnapshot, error) {
|
|
||||||
cfg := newConfig()
|
|
||||||
return monitor.CollectOnce(context.Background(), cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newConfig() monitor.CollectorConfig {
|
|
||||||
return monitor.CollectorConfig{
|
|
||||||
ConfigPath: flagConfig,
|
|
||||||
Env: flagEnv,
|
|
||||||
NodeFilter: flagNode,
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runLive(cmd *cobra.Command, args []string) error {
|
|
||||||
cfg := newConfig()
|
|
||||||
return tui.Run(cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
package namespacecmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cmd is the root command for namespace management.
|
|
||||||
var Cmd = &cobra.Command{
|
|
||||||
Use: "namespace",
|
|
||||||
Aliases: []string{"ns"},
|
|
||||||
Short: "Manage namespaces",
|
|
||||||
Long: `List, delete, and repair namespaces on the Orama network.`,
|
|
||||||
}
|
|
||||||
|
|
||||||
var deleteCmd = &cobra.Command{
|
|
||||||
Use: "delete",
|
|
||||||
Short: "Delete the current namespace and all its resources",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
forceFlag, _ := cmd.Flags().GetBool("force")
|
|
||||||
var cliArgs []string
|
|
||||||
cliArgs = append(cliArgs, "delete")
|
|
||||||
if forceFlag {
|
|
||||||
cliArgs = append(cliArgs, "--force")
|
|
||||||
}
|
|
||||||
cli.HandleNamespaceCommand(cliArgs)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var listCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Aliases: []string{"ls"},
|
|
||||||
Short: "List namespaces owned by the current wallet",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
cli.HandleNamespaceCommand([]string{"list"})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var repairCmd = &cobra.Command{
|
|
||||||
Use: "repair <namespace>",
|
|
||||||
Short: "Repair an under-provisioned namespace cluster",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
cli.HandleNamespaceCommand(append([]string{"repair"}, args...))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var enableCmd = &cobra.Command{
|
|
||||||
Use: "enable <feature>",
|
|
||||||
Short: "Enable a feature for a namespace",
|
|
||||||
Long: "Enable a feature for a namespace. Supported features: webrtc",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
ns, _ := cmd.Flags().GetString("namespace")
|
|
||||||
cliArgs := []string{"enable", args[0]}
|
|
||||||
if ns != "" {
|
|
||||||
cliArgs = append(cliArgs, "--namespace", ns)
|
|
||||||
}
|
|
||||||
cli.HandleNamespaceCommand(cliArgs)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var disableCmd = &cobra.Command{
|
|
||||||
Use: "disable <feature>",
|
|
||||||
Short: "Disable a feature for a namespace",
|
|
||||||
Long: "Disable a feature for a namespace. Supported features: webrtc",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
ns, _ := cmd.Flags().GetString("namespace")
|
|
||||||
cliArgs := []string{"disable", args[0]}
|
|
||||||
if ns != "" {
|
|
||||||
cliArgs = append(cliArgs, "--namespace", ns)
|
|
||||||
}
|
|
||||||
cli.HandleNamespaceCommand(cliArgs)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var webrtcStatusCmd = &cobra.Command{
|
|
||||||
Use: "webrtc-status",
|
|
||||||
Short: "Show WebRTC service status for a namespace",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
ns, _ := cmd.Flags().GetString("namespace")
|
|
||||||
cliArgs := []string{"webrtc-status"}
|
|
||||||
if ns != "" {
|
|
||||||
cliArgs = append(cliArgs, "--namespace", ns)
|
|
||||||
}
|
|
||||||
cli.HandleNamespaceCommand(cliArgs)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
deleteCmd.Flags().Bool("force", false, "Skip confirmation prompt")
|
|
||||||
enableCmd.Flags().String("namespace", "", "Namespace name")
|
|
||||||
disableCmd.Flags().String("namespace", "", "Namespace name")
|
|
||||||
webrtcStatusCmd.Flags().String("namespace", "", "Namespace name")
|
|
||||||
|
|
||||||
Cmd.AddCommand(listCmd)
|
|
||||||
Cmd.AddCommand(deleteCmd)
|
|
||||||
Cmd.AddCommand(repairCmd)
|
|
||||||
Cmd.AddCommand(enableCmd)
|
|
||||||
Cmd.AddCommand(disableCmd)
|
|
||||||
Cmd.AddCommand(webrtcStatusCmd)
|
|
||||||
}
|
|
||||||
@ -1,219 +0,0 @@
|
|||||||
package namespacecmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"crypto/tls"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/auth"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var rqliteCmd = &cobra.Command{
|
|
||||||
Use: "rqlite",
|
|
||||||
Short: "Manage the namespace's internal RQLite database",
|
|
||||||
Long: "Export and import the namespace's internal RQLite database (stores deployments, DNS records, API keys, etc.).",
|
|
||||||
}
|
|
||||||
|
|
||||||
var rqliteExportCmd = &cobra.Command{
|
|
||||||
Use: "export",
|
|
||||||
Short: "Export the namespace's RQLite database to a local SQLite file",
|
|
||||||
Long: "Downloads a consistent SQLite snapshot of the namespace's internal RQLite database.",
|
|
||||||
RunE: rqliteExport,
|
|
||||||
}
|
|
||||||
|
|
||||||
var rqliteImportCmd = &cobra.Command{
|
|
||||||
Use: "import",
|
|
||||||
Short: "Import a SQLite dump into the namespace's RQLite (DESTRUCTIVE)",
|
|
||||||
Long: `Replaces the namespace's entire RQLite database with the contents of the provided SQLite file.
|
|
||||||
|
|
||||||
WARNING: This is a destructive operation. All existing data in the namespace's RQLite
|
|
||||||
(deployments, DNS records, API keys, etc.) will be replaced with the imported file.`,
|
|
||||||
RunE: rqliteImport,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rqliteExportCmd.Flags().StringP("output", "o", "", "Output file path (default: rqlite-export.db)")
|
|
||||||
|
|
||||||
rqliteImportCmd.Flags().StringP("input", "i", "", "Input SQLite file path")
|
|
||||||
_ = rqliteImportCmd.MarkFlagRequired("input")
|
|
||||||
|
|
||||||
rqliteCmd.AddCommand(rqliteExportCmd)
|
|
||||||
rqliteCmd.AddCommand(rqliteImportCmd)
|
|
||||||
|
|
||||||
Cmd.AddCommand(rqliteCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func rqliteExport(cmd *cobra.Command, args []string) error {
|
|
||||||
output, _ := cmd.Flags().GetString("output")
|
|
||||||
if output == "" {
|
|
||||||
output = "rqlite-export.db"
|
|
||||||
}
|
|
||||||
|
|
||||||
apiURL := nsRQLiteAPIURL()
|
|
||||||
token, err := nsRQLiteAuthToken()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
url := apiURL + "/v1/rqlite/export"
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 0,
|
|
||||||
Transport: &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Exporting RQLite database to %s...\n", output)
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to connect to gateway: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("export failed (HTTP %d): %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
outFile, err := os.Create(output)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
|
||||||
}
|
|
||||||
defer outFile.Close()
|
|
||||||
|
|
||||||
written, err := io.Copy(outFile, resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
os.Remove(output)
|
|
||||||
return fmt.Errorf("failed to write export file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Export complete: %s (%d bytes)\n", output, written)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rqliteImport(cmd *cobra.Command, args []string) error {
|
|
||||||
input, _ := cmd.Flags().GetString("input")
|
|
||||||
|
|
||||||
info, err := os.Stat(input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot access input file: %w", err)
|
|
||||||
}
|
|
||||||
if info.IsDir() {
|
|
||||||
return fmt.Errorf("input path is a directory, not a file")
|
|
||||||
}
|
|
||||||
|
|
||||||
store, err := auth.LoadEnhancedCredentials()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load credentials: %w", err)
|
|
||||||
}
|
|
||||||
gatewayURL := auth.GetDefaultGatewayURL()
|
|
||||||
creds := store.GetDefaultCredential(gatewayURL)
|
|
||||||
if creds == nil || !creds.IsValid() {
|
|
||||||
return fmt.Errorf("not authenticated. Run 'orama auth login' first")
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace := creds.Namespace
|
|
||||||
if namespace == "" {
|
|
||||||
namespace = "default"
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("WARNING: This will REPLACE the entire RQLite database for namespace '%s'.\n", namespace)
|
|
||||||
fmt.Printf("All existing data (deployments, DNS records, API keys, etc.) will be lost.\n")
|
|
||||||
fmt.Printf("Importing from: %s (%d bytes)\n\n", input, info.Size())
|
|
||||||
fmt.Printf("Type the namespace name '%s' to confirm: ", namespace)
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
|
||||||
scanner.Scan()
|
|
||||||
confirmation := strings.TrimSpace(scanner.Text())
|
|
||||||
if confirmation != namespace {
|
|
||||||
return fmt.Errorf("aborted - namespace name did not match")
|
|
||||||
}
|
|
||||||
|
|
||||||
apiURL := nsRQLiteAPIURL()
|
|
||||||
token, err := nsRQLiteAuthToken()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(input)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open input file: %w", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
url := apiURL + "/v1/rqlite/import"
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPost, url, file)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
req.Header.Set("Content-Type", "application/octet-stream")
|
|
||||||
req.ContentLength = info.Size()
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Timeout: 0,
|
|
||||||
Transport: &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Importing database...\n")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to connect to gateway: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("import failed (HTTP %d): %s", resp.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Import complete. The namespace '%s' RQLite database has been replaced.\n", namespace)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func nsRQLiteAPIURL() string {
|
|
||||||
if url := os.Getenv("ORAMA_API_URL"); url != "" {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
return auth.GetDefaultGatewayURL()
|
|
||||||
}
|
|
||||||
|
|
||||||
func nsRQLiteAuthToken() (string, error) {
|
|
||||||
if token := os.Getenv("ORAMA_TOKEN"); token != "" {
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
store, err := auth.LoadEnhancedCredentials()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to load credentials: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gatewayURL := auth.GetDefaultGatewayURL()
|
|
||||||
creds := store.GetDefaultCredential(gatewayURL)
|
|
||||||
if creds == nil {
|
|
||||||
return "", fmt.Errorf("no credentials found for %s. Run 'orama auth login' to authenticate", gatewayURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !creds.IsValid() {
|
|
||||||
return "", fmt.Errorf("credentials expired for %s. Run 'orama auth login' to re-authenticate", gatewayURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
return creds.APIKey, nil
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
package node
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/production/clean"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var cleanCmd = &cobra.Command{
|
|
||||||
Use: "clean",
|
|
||||||
Short: "Clean (wipe) remote nodes for reinstallation",
|
|
||||||
Long: `Remove all Orama data, services, and configuration from remote nodes.
|
|
||||||
Anyone relay keys at /var/lib/anon/ are preserved.
|
|
||||||
|
|
||||||
This is a DESTRUCTIVE operation. Use --force to skip confirmation.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
orama node clean --env testnet # Clean all testnet nodes
|
|
||||||
orama node clean --env testnet --node 1.2.3.4 # Clean specific node
|
|
||||||
orama node clean --env testnet --nuclear # Also remove shared binaries
|
|
||||||
orama node clean --env testnet --force # Skip confirmation`,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
clean.Handle(args)
|
|
||||||
},
|
|
||||||
DisableFlagParsing: true,
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
package node
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/production/enroll"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var enrollCmd = &cobra.Command{
|
|
||||||
Use: "enroll",
|
|
||||||
Short: "Enroll an OramaOS node into the cluster",
|
|
||||||
Long: `Enroll a freshly booted OramaOS node into the cluster.
|
|
||||||
|
|
||||||
The OramaOS node displays a registration code on port 9999. Provide this code
|
|
||||||
along with an invite token to complete enrollment. The Gateway pushes cluster
|
|
||||||
configuration (WireGuard, secrets, peer list) to the node.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
orama node enroll --node-ip <ip> --code <code> --token <invite-token> --env <environment>
|
|
||||||
|
|
||||||
The node must be reachable over the public internet on port 9999 (enrollment only).
|
|
||||||
After enrollment, port 9999 is permanently closed and all communication goes over WireGuard.`,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
enroll.Handle(args)
|
|
||||||
},
|
|
||||||
DisableFlagParsing: true,
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package node
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/production/push"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var pushCmd = &cobra.Command{
|
|
||||||
Use: "push",
|
|
||||||
Short: "Push binary archive to remote nodes",
|
|
||||||
Long: `Upload a pre-built binary archive to remote nodes.
|
|
||||||
|
|
||||||
By default, uses fanout distribution: uploads to one hub node,
|
|
||||||
then distributes to all others via server-to-server SCP.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
orama node push --env devnet # Fanout to all devnet nodes
|
|
||||||
orama node push --env testnet --node 1.2.3.4 # Single node
|
|
||||||
orama node push --env testnet --direct # Sequential upload to each node`,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
push.Handle(args)
|
|
||||||
},
|
|
||||||
DisableFlagParsing: true,
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
package node
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/production/recover"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var recoverRaftCmd = &cobra.Command{
|
|
||||||
Use: "recover-raft",
|
|
||||||
Short: "Recover RQLite cluster from split-brain",
|
|
||||||
Long: `Recover the RQLite Raft cluster from split-brain failure.
|
|
||||||
|
|
||||||
Strategy:
|
|
||||||
1. Stop orama-node on ALL nodes simultaneously
|
|
||||||
2. Backup and delete raft/ on non-leader nodes
|
|
||||||
3. Start leader node, wait for Leader state
|
|
||||||
4. Start remaining nodes in batches
|
|
||||||
5. Verify cluster health
|
|
||||||
|
|
||||||
The --leader flag must point to the node with the highest commit index.
|
|
||||||
|
|
||||||
This is a DESTRUCTIVE operation. Use --force to skip confirmation.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
orama node recover-raft --env testnet --leader 1.2.3.4
|
|
||||||
orama node recover-raft --env devnet --leader 1.2.3.4 --force`,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
recover.Handle(args)
|
|
||||||
},
|
|
||||||
DisableFlagParsing: true,
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
package node
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/production/report"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var reportCmd = &cobra.Command{
|
|
||||||
Use: "report",
|
|
||||||
Short: "Output comprehensive node health data as JSON",
|
|
||||||
Long: `Collect all system and service data from this node and output
|
|
||||||
as a single JSON blob. Designed to be called by 'orama monitor' over SSH.
|
|
||||||
Requires root privileges for full data collection.`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
jsonFlag, _ := cmd.Flags().GetBool("json")
|
|
||||||
return report.Handle(jsonFlag, "")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
reportCmd.Flags().Bool("json", true, "Output as JSON (default)")
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
package node
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/production/rollout"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var rolloutCmd = &cobra.Command{
|
|
||||||
Use: "rollout",
|
|
||||||
Short: "Build, push, and rolling upgrade all nodes in an environment",
|
|
||||||
Long: `Full deployment pipeline: build binary archive locally, push to all nodes,
|
|
||||||
then perform a rolling upgrade (one node at a time).
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
orama node rollout --env testnet # Full: build + push + rolling upgrade
|
|
||||||
orama node rollout --env testnet --no-build # Skip build, use existing archive
|
|
||||||
orama node rollout --env testnet --yes # Skip confirmation`,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
rollout.Handle(args)
|
|
||||||
},
|
|
||||||
DisableFlagParsing: true,
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
package node
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/production/unlock"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var unlockCmd = &cobra.Command{
|
|
||||||
Use: "unlock",
|
|
||||||
Short: "Unlock an OramaOS genesis node",
|
|
||||||
Long: `Manually unlock a genesis OramaOS node that cannot reconstruct its LUKS key
|
|
||||||
via Shamir shares (not enough peers online).
|
|
||||||
|
|
||||||
This is only needed for the genesis node before enough peers have joined for
|
|
||||||
Shamir-based unlock. Once 5+ peers exist, the genesis node transitions to
|
|
||||||
normal Shamir unlock and this command is no longer needed.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
orama node unlock --genesis --node-ip <wg-ip>
|
|
||||||
|
|
||||||
The node must be reachable over WireGuard on port 9998.`,
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
unlock.Handle(args)
|
|
||||||
},
|
|
||||||
DisableFlagParsing: true,
|
|
||||||
}
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
package sandboxcmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/sandbox"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cmd is the root command for sandbox operations.
|
|
||||||
var Cmd = &cobra.Command{
|
|
||||||
Use: "sandbox",
|
|
||||||
Short: "Manage ephemeral Hetzner Cloud clusters for testing",
|
|
||||||
Long: `Spin up temporary 5-node Orama clusters on Hetzner Cloud for development and testing.
|
|
||||||
|
|
||||||
Setup (one-time):
|
|
||||||
orama sandbox setup
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
orama sandbox create [--name <name>] Create a new 5-node cluster
|
|
||||||
orama sandbox destroy [--name <name>] Tear down a cluster
|
|
||||||
orama sandbox list List active sandboxes
|
|
||||||
orama sandbox status [--name <name>] Show cluster health
|
|
||||||
orama sandbox rollout [--name <name>] Build + push + rolling upgrade
|
|
||||||
orama sandbox ssh <node-number> SSH into a sandbox node (1-5)
|
|
||||||
orama sandbox reset Delete all infra and config to start fresh`,
|
|
||||||
}
|
|
||||||
|
|
||||||
var setupCmd = &cobra.Command{
|
|
||||||
Use: "setup",
|
|
||||||
Short: "Interactive setup: Hetzner API key, domain, floating IPs, SSH key",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return sandbox.Setup()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var createCmd = &cobra.Command{
|
|
||||||
Use: "create",
|
|
||||||
Short: "Create a new 5-node sandbox cluster (~5 min)",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
name, _ := cmd.Flags().GetString("name")
|
|
||||||
return sandbox.Create(name)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var destroyCmd = &cobra.Command{
|
|
||||||
Use: "destroy",
|
|
||||||
Short: "Destroy a sandbox cluster and release resources",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
name, _ := cmd.Flags().GetString("name")
|
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
|
||||||
return sandbox.Destroy(name, force)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var listCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List active sandbox clusters",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return sandbox.List()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var statusCmd = &cobra.Command{
|
|
||||||
Use: "status",
|
|
||||||
Short: "Show cluster health report",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
name, _ := cmd.Flags().GetString("name")
|
|
||||||
return sandbox.Status(name)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var rolloutCmd = &cobra.Command{
|
|
||||||
Use: "rollout",
|
|
||||||
Short: "Build + push + rolling upgrade to sandbox cluster",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
name, _ := cmd.Flags().GetString("name")
|
|
||||||
anyoneClient, _ := cmd.Flags().GetBool("anyone-client")
|
|
||||||
return sandbox.Rollout(name, sandbox.RolloutFlags{
|
|
||||||
AnyoneClient: anyoneClient,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var resetCmd = &cobra.Command{
|
|
||||||
Use: "reset",
|
|
||||||
Short: "Delete all sandbox infrastructure and config to start fresh",
|
|
||||||
Long: `Deletes floating IPs, firewall, and SSH key from Hetzner Cloud,
|
|
||||||
then removes the local config (~/.orama/sandbox.yaml) and SSH keys.
|
|
||||||
|
|
||||||
Use this when you need to switch datacenter locations (floating IPs are
|
|
||||||
location-bound) or to completely start over with sandbox setup.`,
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return sandbox.Reset()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var sshCmd = &cobra.Command{
|
|
||||||
Use: "ssh <node-number>",
|
|
||||||
Short: "SSH into a sandbox node (1-5)",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
name, _ := cmd.Flags().GetString("name")
|
|
||||||
var nodeNum int
|
|
||||||
if _, err := fmt.Sscanf(args[0], "%d", &nodeNum); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Invalid node number: %s (expected 1-5)\n", args[0])
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
return sandbox.SSHInto(name, nodeNum)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
// create flags
|
|
||||||
createCmd.Flags().String("name", "", "Sandbox name (random if not specified)")
|
|
||||||
|
|
||||||
// destroy flags
|
|
||||||
destroyCmd.Flags().String("name", "", "Sandbox name (uses active if not specified)")
|
|
||||||
destroyCmd.Flags().Bool("force", false, "Skip confirmation")
|
|
||||||
|
|
||||||
// status flags
|
|
||||||
statusCmd.Flags().String("name", "", "Sandbox name (uses active if not specified)")
|
|
||||||
|
|
||||||
// rollout flags
|
|
||||||
rolloutCmd.Flags().String("name", "", "Sandbox name (uses active if not specified)")
|
|
||||||
rolloutCmd.Flags().Bool("anyone-client", false, "Enable Anyone client (SOCKS5 proxy) on all nodes")
|
|
||||||
|
|
||||||
// ssh flags
|
|
||||||
sshCmd.Flags().String("name", "", "Sandbox name (uses active if not specified)")
|
|
||||||
|
|
||||||
Cmd.AddCommand(setupCmd)
|
|
||||||
Cmd.AddCommand(createCmd)
|
|
||||||
Cmd.AddCommand(destroyCmd)
|
|
||||||
Cmd.AddCommand(listCmd)
|
|
||||||
Cmd.AddCommand(statusCmd)
|
|
||||||
Cmd.AddCommand(rolloutCmd)
|
|
||||||
Cmd.AddCommand(sshCmd)
|
|
||||||
Cmd.AddCommand(resetCmd)
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
package functions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BuildCmd compiles a function to WASM using TinyGo.
|
|
||||||
var BuildCmd = &cobra.Command{
|
|
||||||
Use: "build [directory]",
|
|
||||||
Short: "Build a function to WASM using TinyGo",
|
|
||||||
Long: `Compiles function.go in the given directory (or current directory) to a WASM binary.
|
|
||||||
Requires TinyGo to be installed (https://tinygo.org/getting-started/install/).`,
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
RunE: runBuild,
|
|
||||||
}
|
|
||||||
|
|
||||||
func runBuild(cmd *cobra.Command, args []string) error {
|
|
||||||
dir := ""
|
|
||||||
if len(args) > 0 {
|
|
||||||
dir = args[0]
|
|
||||||
}
|
|
||||||
_, err := buildFunction(dir)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildFunction compiles the function in dir and returns the path to the WASM output.
|
|
||||||
func buildFunction(dir string) (string, error) {
|
|
||||||
absDir, err := ResolveFunctionDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify function.go exists
|
|
||||||
goFile := filepath.Join(absDir, "function.go")
|
|
||||||
if _, err := os.Stat(goFile); os.IsNotExist(err) {
|
|
||||||
return "", fmt.Errorf("function.go not found in %s", absDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify function.yaml exists
|
|
||||||
if _, err := os.Stat(filepath.Join(absDir, "function.yaml")); os.IsNotExist(err) {
|
|
||||||
return "", fmt.Errorf("function.yaml not found in %s", absDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check TinyGo is installed
|
|
||||||
tinygoPath, err := exec.LookPath("tinygo")
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("tinygo not found in PATH. Install it: https://tinygo.org/getting-started/install/")
|
|
||||||
}
|
|
||||||
|
|
||||||
outputPath := filepath.Join(absDir, "function.wasm")
|
|
||||||
|
|
||||||
fmt.Printf("Building %s...\n", absDir)
|
|
||||||
|
|
||||||
// Run tinygo build
|
|
||||||
buildCmd := exec.Command(tinygoPath, "build", "-o", outputPath, "-target", "wasi", ".")
|
|
||||||
buildCmd.Dir = absDir
|
|
||||||
buildCmd.Stdout = os.Stdout
|
|
||||||
buildCmd.Stderr = os.Stderr
|
|
||||||
|
|
||||||
if err := buildCmd.Run(); err != nil {
|
|
||||||
return "", fmt.Errorf("tinygo build failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate output
|
|
||||||
if err := ValidateWASMFile(outputPath); err != nil {
|
|
||||||
os.Remove(outputPath)
|
|
||||||
return "", fmt.Errorf("build produced invalid WASM: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
info, _ := os.Stat(outputPath)
|
|
||||||
fmt.Printf("Built %s (%d bytes)\n", outputPath, info.Size())
|
|
||||||
|
|
||||||
return outputPath, nil
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
package functions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var deleteForce bool
|
|
||||||
|
|
||||||
// DeleteCmd deletes a deployed function.
|
|
||||||
var DeleteCmd = &cobra.Command{
|
|
||||||
Use: "delete <name>",
|
|
||||||
Short: "Delete a deployed function",
|
|
||||||
Long: "Deletes a function from the Orama Network. This action cannot be undone.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runDelete,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
DeleteCmd.Flags().BoolVarP(&deleteForce, "force", "f", false, "Skip confirmation prompt")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDelete(cmd *cobra.Command, args []string) error {
|
|
||||||
name := args[0]
|
|
||||||
|
|
||||||
if !deleteForce {
|
|
||||||
fmt.Printf("Are you sure you want to delete function %q? This cannot be undone. [y/N] ", name)
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
answer, _ := reader.ReadString('\n')
|
|
||||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
|
||||||
if answer != "y" && answer != "yes" {
|
|
||||||
fmt.Println("Cancelled.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := apiDelete("/v1/functions/" + name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg, ok := result["message"]; ok {
|
|
||||||
fmt.Println(msg)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Function %q deleted.\n", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
package functions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DeployCmd deploys a function to the Orama Network.
|
|
||||||
var DeployCmd = &cobra.Command{
|
|
||||||
Use: "deploy [directory]",
|
|
||||||
Short: "Deploy a function to the Orama Network",
|
|
||||||
Long: `Deploys the function in the given directory (or current directory).
|
|
||||||
If no .wasm file exists, it will be built automatically using TinyGo.
|
|
||||||
Reads configuration from function.yaml.`,
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
RunE: runDeploy,
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDeploy(cmd *cobra.Command, args []string) error {
|
|
||||||
dir := ""
|
|
||||||
if len(args) > 0 {
|
|
||||||
dir = args[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
absDir, err := ResolveFunctionDir(dir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load configuration
|
|
||||||
cfg, err := LoadConfig(absDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
wasmPath := filepath.Join(absDir, "function.wasm")
|
|
||||||
|
|
||||||
// Auto-build if no WASM file exists
|
|
||||||
if _, err := os.Stat(wasmPath); os.IsNotExist(err) {
|
|
||||||
fmt.Printf("No function.wasm found, building...\n\n")
|
|
||||||
built, err := buildFunction(dir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
wasmPath = built
|
|
||||||
fmt.Println()
|
|
||||||
} else {
|
|
||||||
// Validate existing WASM
|
|
||||||
if err := ValidateWASMFile(wasmPath); err != nil {
|
|
||||||
return fmt.Errorf("existing function.wasm is invalid: %w\nRun 'orama function build' to rebuild", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Deploying function %q...\n", cfg.Name)
|
|
||||||
|
|
||||||
result, err := uploadWASMFunction(wasmPath, cfg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\nFunction deployed successfully!\n\n")
|
|
||||||
|
|
||||||
if msg, ok := result["message"]; ok {
|
|
||||||
fmt.Printf(" %s\n", msg)
|
|
||||||
}
|
|
||||||
if fn, ok := result["function"].(map[string]interface{}); ok {
|
|
||||||
if id, ok := fn["id"]; ok {
|
|
||||||
fmt.Printf(" ID: %s\n", id)
|
|
||||||
}
|
|
||||||
fmt.Printf(" Name: %s\n", cfg.Name)
|
|
||||||
if v, ok := fn["version"]; ok {
|
|
||||||
fmt.Printf(" Version: %v\n", v)
|
|
||||||
}
|
|
||||||
if wc, ok := fn["wasm_cid"]; ok {
|
|
||||||
fmt.Printf(" WASM CID: %s\n", wc)
|
|
||||||
}
|
|
||||||
if st, ok := fn["status"]; ok {
|
|
||||||
fmt.Printf(" Status: %s\n", st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\nInvoke with:\n")
|
|
||||||
fmt.Printf(" orama function invoke %s --data '{\"name\": \"World\"}'\n", cfg.Name)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
package functions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetCmd shows details of a deployed function.
|
|
||||||
var GetCmd = &cobra.Command{
|
|
||||||
Use: "get <name>",
|
|
||||||
Short: "Get details of a deployed function",
|
|
||||||
Long: "Retrieves and displays detailed information about a specific function.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runGet,
|
|
||||||
}
|
|
||||||
|
|
||||||
func runGet(cmd *cobra.Command, args []string) error {
|
|
||||||
name := args[0]
|
|
||||||
|
|
||||||
result, err := apiGet("/v1/functions/" + name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pretty-print the result
|
|
||||||
data, err := json.MarshalIndent(result, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to format response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(string(data))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,260 +0,0 @@
|
|||||||
package functions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"mime/multipart"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/shared"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FunctionConfig represents the function.yaml configuration.
|
|
||||||
type FunctionConfig struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
Public bool `yaml:"public"`
|
|
||||||
Memory int `yaml:"memory"`
|
|
||||||
Timeout int `yaml:"timeout"`
|
|
||||||
Retry RetryConfig `yaml:"retry"`
|
|
||||||
Env map[string]string `yaml:"env"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RetryConfig holds retry settings.
|
|
||||||
type RetryConfig struct {
|
|
||||||
Count int `yaml:"count"`
|
|
||||||
Delay int `yaml:"delay"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// wasmMagicBytes is the WASM binary magic number: \0asm
|
|
||||||
var wasmMagicBytes = []byte{0x00, 0x61, 0x73, 0x6d}
|
|
||||||
|
|
||||||
// validNameRegex validates function names (alphanumeric, hyphens, underscores).
|
|
||||||
var validNameRegex = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_-]*$`)
|
|
||||||
|
|
||||||
// LoadConfig reads and parses a function.yaml from the given directory.
|
|
||||||
func LoadConfig(dir string) (*FunctionConfig, error) {
|
|
||||||
path := filepath.Join(dir, "function.yaml")
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read function.yaml: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var cfg FunctionConfig
|
|
||||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse function.yaml: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply defaults
|
|
||||||
if cfg.Memory == 0 {
|
|
||||||
cfg.Memory = 64
|
|
||||||
}
|
|
||||||
if cfg.Timeout == 0 {
|
|
||||||
cfg.Timeout = 30
|
|
||||||
}
|
|
||||||
if cfg.Retry.Delay == 0 {
|
|
||||||
cfg.Retry.Delay = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate
|
|
||||||
if cfg.Name == "" {
|
|
||||||
return nil, fmt.Errorf("function.yaml: 'name' is required")
|
|
||||||
}
|
|
||||||
if !validNameRegex.MatchString(cfg.Name) {
|
|
||||||
return nil, fmt.Errorf("function.yaml: 'name' must start with a letter and contain only letters, digits, hyphens, or underscores")
|
|
||||||
}
|
|
||||||
if cfg.Memory < 1 || cfg.Memory > 256 {
|
|
||||||
return nil, fmt.Errorf("function.yaml: 'memory' must be between 1 and 256 MB (got %d)", cfg.Memory)
|
|
||||||
}
|
|
||||||
if cfg.Timeout < 1 || cfg.Timeout > 300 {
|
|
||||||
return nil, fmt.Errorf("function.yaml: 'timeout' must be between 1 and 300 seconds (got %d)", cfg.Timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateWASM checks that the given bytes are a valid WASM binary (magic number check).
|
|
||||||
func ValidateWASM(data []byte) error {
|
|
||||||
if len(data) < 8 {
|
|
||||||
return fmt.Errorf("file too small to be a valid WASM binary (%d bytes)", len(data))
|
|
||||||
}
|
|
||||||
if !bytes.HasPrefix(data, wasmMagicBytes) {
|
|
||||||
return fmt.Errorf("file is not a valid WASM binary (bad magic bytes)")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateWASMFile checks that the file at the given path is a valid WASM binary.
|
|
||||||
func ValidateWASMFile(path string) error {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open WASM file: %w", err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
header := make([]byte, 8)
|
|
||||||
n, err := f.Read(header)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read WASM file: %w", err)
|
|
||||||
}
|
|
||||||
return ValidateWASM(header[:n])
|
|
||||||
}
|
|
||||||
|
|
||||||
// apiRequest performs an authenticated HTTP request to the gateway API.
|
|
||||||
func apiRequest(method, endpoint string, body io.Reader, contentType string) (*http.Response, error) {
|
|
||||||
apiURL := shared.GetAPIURL()
|
|
||||||
url := apiURL + endpoint
|
|
||||||
|
|
||||||
req, err := http.NewRequest(method, url, body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if contentType != "" {
|
|
||||||
req.Header.Set("Content-Type", contentType)
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := shared.GetAuthToken()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("authentication required: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
|
|
||||||
return http.DefaultClient.Do(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
// apiGet performs an authenticated GET request and returns the parsed JSON response.
|
|
||||||
func apiGet(endpoint string) (map[string]interface{}, error) {
|
|
||||||
resp, err := apiRequest("GET", endpoint, nil, "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// apiDelete performs an authenticated DELETE request and returns the parsed JSON response.
|
|
||||||
func apiDelete(endpoint string) (map[string]interface{}, error) {
|
|
||||||
resp, err := apiRequest("DELETE", endpoint, nil, "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadWASMFunction uploads a WASM file to the deploy endpoint via multipart/form-data.
|
|
||||||
func uploadWASMFunction(wasmPath string, cfg *FunctionConfig) (map[string]interface{}, error) {
|
|
||||||
wasmFile, err := os.Open(wasmPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open WASM file: %w", err)
|
|
||||||
}
|
|
||||||
defer wasmFile.Close()
|
|
||||||
|
|
||||||
body := &bytes.Buffer{}
|
|
||||||
writer := multipart.NewWriter(body)
|
|
||||||
|
|
||||||
// Add form fields
|
|
||||||
writer.WriteField("name", cfg.Name)
|
|
||||||
writer.WriteField("is_public", strconv.FormatBool(cfg.Public))
|
|
||||||
writer.WriteField("memory_limit_mb", strconv.Itoa(cfg.Memory))
|
|
||||||
writer.WriteField("timeout_seconds", strconv.Itoa(cfg.Timeout))
|
|
||||||
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
|
|
||||||
if len(cfg.Env) > 0 {
|
|
||||||
metadata, _ := json.Marshal(map[string]interface{}{
|
|
||||||
"env_vars": cfg.Env,
|
|
||||||
})
|
|
||||||
writer.WriteField("metadata", string(metadata))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add WASM file
|
|
||||||
part, err := writer.CreateFormFile("wasm", filepath.Base(wasmPath))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create form file: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(part, wasmFile); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write WASM data: %w", err)
|
|
||||||
}
|
|
||||||
writer.Close()
|
|
||||||
|
|
||||||
resp, err := apiRequest("POST", "/v1/functions", body, writer.FormDataContentType())
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("deploy failed (%d): %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveFunctionDir resolves and validates a function directory.
|
|
||||||
// If dir is empty, uses the current working directory.
|
|
||||||
func ResolveFunctionDir(dir string) (string, error) {
|
|
||||||
if dir == "" {
|
|
||||||
dir = "."
|
|
||||||
}
|
|
||||||
absDir, err := filepath.Abs(dir)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to resolve path: %w", err)
|
|
||||||
}
|
|
||||||
info, err := os.Stat(absDir)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("directory does not exist: %w", err)
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
return "", fmt.Errorf("%s is not a directory", absDir)
|
|
||||||
}
|
|
||||||
return absDir, nil
|
|
||||||
}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
package functions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// InitCmd scaffolds a new function project.
|
|
||||||
var InitCmd = &cobra.Command{
|
|
||||||
Use: "init <name>",
|
|
||||||
Short: "Create a new serverless function project",
|
|
||||||
Long: "Scaffolds a new directory with function.go and function.yaml templates.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runInit,
|
|
||||||
}
|
|
||||||
|
|
||||||
func runInit(cmd *cobra.Command, args []string) error {
|
|
||||||
name := args[0]
|
|
||||||
|
|
||||||
if !validNameRegex.MatchString(name) {
|
|
||||||
return fmt.Errorf("invalid function name %q: must start with a letter and contain only letters, digits, hyphens, or underscores", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
dir := filepath.Join(".", name)
|
|
||||||
if _, err := os.Stat(dir); err == nil {
|
|
||||||
return fmt.Errorf("directory %q already exists", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write function.yaml
|
|
||||||
yamlContent := fmt.Sprintf(`name: %s
|
|
||||||
public: false
|
|
||||||
memory: 64
|
|
||||||
timeout: 30
|
|
||||||
retry:
|
|
||||||
count: 0
|
|
||||||
delay: 5
|
|
||||||
`, name)
|
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "function.yaml"), []byte(yamlContent), 0o644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write function.yaml: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write function.go
|
|
||||||
goContent := fmt.Sprintf(`package main
|
|
||||||
|
|
||||||
import "github.com/DeBrosOfficial/network/sdk/fn"
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
fn.Run(func(input []byte) ([]byte, error) {
|
|
||||||
var req struct {
|
|
||||||
Name string `+"`"+`json:"name"`+"`"+`
|
|
||||||
}
|
|
||||||
fn.ParseJSON(input, &req)
|
|
||||||
if req.Name == "" {
|
|
||||||
req.Name = "World"
|
|
||||||
}
|
|
||||||
return fn.JSON(map[string]string{
|
|
||||||
"greeting": "Hello, " + req.Name + "!",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
`)
|
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "function.go"), []byte(goContent), 0o644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write function.go: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Created function project: %s/\n", name)
|
|
||||||
fmt.Printf(" %s/function.yaml — configuration\n", name)
|
|
||||||
fmt.Printf(" %s/function.go — handler code\n\n", name)
|
|
||||||
fmt.Printf("Next steps:\n")
|
|
||||||
fmt.Printf(" cd %s\n", name)
|
|
||||||
fmt.Printf(" orama function build\n")
|
|
||||||
fmt.Printf(" orama function deploy\n")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
package functions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var invokeData string
|
|
||||||
|
|
||||||
// InvokeCmd invokes a deployed function.
|
|
||||||
var InvokeCmd = &cobra.Command{
|
|
||||||
Use: "invoke <name>",
|
|
||||||
Short: "Invoke a deployed function",
|
|
||||||
Long: "Sends a request to invoke the named function with optional JSON payload.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runInvoke,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
InvokeCmd.Flags().StringVar(&invokeData, "data", "{}", "JSON payload to send to the function")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runInvoke(cmd *cobra.Command, args []string) error {
|
|
||||||
name := args[0]
|
|
||||||
|
|
||||||
fmt.Printf("Invoking function %q...\n\n", name)
|
|
||||||
|
|
||||||
resp, err := apiRequest("POST", "/v1/functions/"+name+"/invoke", bytes.NewBufferString(invokeData), "application/json")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print timing info from headers
|
|
||||||
if reqID := resp.Header.Get("X-Request-ID"); reqID != "" {
|
|
||||||
fmt.Printf("Request ID: %s\n", reqID)
|
|
||||||
}
|
|
||||||
if dur := resp.Header.Get("X-Duration-Ms"); dur != "" {
|
|
||||||
fmt.Printf("Duration: %s ms\n", dur)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("invocation failed (%d): %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\nOutput:\n%s\n", string(respBody))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
package functions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"text/tabwriter"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ListCmd lists all deployed functions.
|
|
||||||
var ListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List deployed functions",
|
|
||||||
Long: "Lists all functions deployed in the current namespace.",
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: runList,
|
|
||||||
}
|
|
||||||
|
|
||||||
func runList(cmd *cobra.Command, args []string) error {
|
|
||||||
result, err := apiGet("/v1/functions")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
functions, ok := result["functions"].([]interface{})
|
|
||||||
if !ok || len(functions) == 0 {
|
|
||||||
fmt.Println("No functions deployed.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
||||||
fmt.Fprintln(w, "NAME\tVERSION\tSTATUS\tMEMORY\tTIMEOUT\tPUBLIC")
|
|
||||||
fmt.Fprintln(w, "----\t-------\t------\t------\t-------\t------")
|
|
||||||
|
|
||||||
for _, f := range functions {
|
|
||||||
fn, ok := f.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := valStr(fn, "name")
|
|
||||||
version := valNum(fn, "version")
|
|
||||||
status := valStr(fn, "status")
|
|
||||||
memory := valNum(fn, "memory_limit_mb")
|
|
||||||
timeout := valNum(fn, "timeout_seconds")
|
|
||||||
public := valBool(fn, "is_public")
|
|
||||||
|
|
||||||
publicStr := "no"
|
|
||||||
if public {
|
|
||||||
publicStr = "yes"
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(w, "%s\t%d\t%s\t%dMB\t%ds\t%s\n", name, version, status, memory, timeout, publicStr)
|
|
||||||
}
|
|
||||||
w.Flush()
|
|
||||||
|
|
||||||
fmt.Printf("\nTotal: %d function(s)\n", len(functions))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func valStr(m map[string]interface{}, key string) string {
|
|
||||||
if v, ok := m[key]; ok {
|
|
||||||
return fmt.Sprintf("%v", v)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func valNum(m map[string]interface{}, key string) int {
|
|
||||||
if v, ok := m[key].(float64); ok {
|
|
||||||
return int(v)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func valBool(m map[string]interface{}, key string) bool {
|
|
||||||
if v, ok := m[key].(bool); ok {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
package functions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var logsLimit int
|
|
||||||
|
|
||||||
// LogsCmd retrieves function execution logs.
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
LogsCmd.Flags().IntVar(&logsLimit, "limit", 50, "Maximum number of log entries to retrieve")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runLogs(cmd *cobra.Command, args []string) error {
|
|
||||||
name := args[0]
|
|
||||||
|
|
||||||
endpoint := "/v1/functions/" + name + "/logs"
|
|
||||||
if logsLimit > 0 {
|
|
||||||
endpoint += "?limit=" + strconv.Itoa(logsLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := apiGet(endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
logs, ok := result["logs"].([]interface{})
|
|
||||||
if !ok || len(logs) == 0 {
|
|
||||||
fmt.Printf("No logs found for function %q.\n", name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, entry := range logs {
|
|
||||||
log, ok := entry.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ts := valStr(log, "timestamp")
|
|
||||||
level := valStr(log, "level")
|
|
||||||
msg := valStr(log, "message")
|
|
||||||
fmt.Printf("[%s] %s: %s\n", ts, level, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\nShowing %d log(s)\n", len(logs))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
package functions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
secretsDeleteForce bool
|
|
||||||
secretsFromFile string
|
|
||||||
)
|
|
||||||
|
|
||||||
// SecretsCmd is the parent command for secrets management.
|
|
||||||
var SecretsCmd = &cobra.Command{
|
|
||||||
Use: "secrets",
|
|
||||||
Short: "Manage function secrets",
|
|
||||||
Long: `Set, list, and delete encrypted secrets for your serverless functions.
|
|
||||||
|
|
||||||
Functions access secrets at runtime via the get_secret() host function.
|
|
||||||
Secrets are scoped to your namespace and encrypted at rest with AES-256-GCM.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
orama function secrets set API_KEY "sk-abc123"
|
|
||||||
orama function secrets set CERT_PEM --from-file ./cert.pem
|
|
||||||
orama function secrets list
|
|
||||||
orama function secrets delete API_KEY`,
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecretsSetCmd stores an encrypted secret.
|
|
||||||
var SecretsSetCmd = &cobra.Command{
|
|
||||||
Use: "set <name> [value]",
|
|
||||||
Short: "Set a secret",
|
|
||||||
Long: `Stores an encrypted secret. Functions access it via get_secret("name"). If --from-file is used, value is read from the file instead.`,
|
|
||||||
Args: cobra.RangeArgs(1, 2),
|
|
||||||
RunE: runSecretsSet,
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecretsListCmd lists secret names.
|
|
||||||
var SecretsListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List secret names",
|
|
||||||
Long: "Lists all secret names in the current namespace. Values are never shown.",
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: runSecretsList,
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecretsDeleteCmd deletes a secret.
|
|
||||||
var SecretsDeleteCmd = &cobra.Command{
|
|
||||||
Use: "delete <name>",
|
|
||||||
Short: "Delete a secret",
|
|
||||||
Long: "Permanently deletes a secret. Functions will no longer be able to access it.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runSecretsDelete,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
SecretsCmd.AddCommand(SecretsSetCmd)
|
|
||||||
SecretsCmd.AddCommand(SecretsListCmd)
|
|
||||||
SecretsCmd.AddCommand(SecretsDeleteCmd)
|
|
||||||
|
|
||||||
SecretsSetCmd.Flags().StringVar(&secretsFromFile, "from-file", "", "Read secret value from a file")
|
|
||||||
SecretsDeleteCmd.Flags().BoolVarP(&secretsDeleteForce, "force", "f", false, "Skip confirmation prompt")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runSecretsSet(cmd *cobra.Command, args []string) error {
|
|
||||||
name := args[0]
|
|
||||||
|
|
||||||
var value string
|
|
||||||
if secretsFromFile != "" {
|
|
||||||
data, err := os.ReadFile(secretsFromFile)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read file %s: %w", secretsFromFile, err)
|
|
||||||
}
|
|
||||||
value = string(data)
|
|
||||||
} else if len(args) >= 2 {
|
|
||||||
value = args[1]
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("secret value required: provide as argument or use --from-file")
|
|
||||||
}
|
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
|
||||||
"name": name,
|
|
||||||
"value": value,
|
|
||||||
})
|
|
||||||
|
|
||||||
resp, err := apiRequest("PUT", "/v1/functions/secrets", bytes.NewReader(body), "application/json")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Secret %q set successfully.\n", name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runSecretsList(cmd *cobra.Command, args []string) error {
|
|
||||||
result, err := apiGet("/v1/functions/secrets")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
secrets, _ := result["secrets"].([]interface{})
|
|
||||||
if len(secrets) == 0 {
|
|
||||||
fmt.Println("No secrets found.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Secrets (%d):\n", len(secrets))
|
|
||||||
for _, s := range secrets {
|
|
||||||
fmt.Printf(" %s\n", s)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runSecretsDelete(cmd *cobra.Command, args []string) error {
|
|
||||||
name := args[0]
|
|
||||||
|
|
||||||
if !secretsDeleteForce {
|
|
||||||
fmt.Printf("Are you sure you want to delete secret %q? [y/N] ", name)
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
answer, _ := reader.ReadString('\n')
|
|
||||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
|
||||||
if answer != "y" && answer != "yes" {
|
|
||||||
fmt.Println("Cancelled.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := apiDelete("/v1/functions/secrets/" + name)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg, ok := result["message"]; ok {
|
|
||||||
fmt.Println(msg)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Secret %q deleted.\n", name)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
package functions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"text/tabwriter"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var triggerTopic 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.
|
|
||||||
|
|
||||||
When a message is published to a topic, all functions with a trigger on
|
|
||||||
that topic are automatically invoked with the message as input.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
orama function triggers add my-function --topic calls:invite
|
|
||||||
orama function triggers list my-function
|
|
||||||
orama function triggers delete my-function <trigger-id>`,
|
|
||||||
}
|
|
||||||
|
|
||||||
// TriggersAddCmd adds a PubSub 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,
|
|
||||||
}
|
|
||||||
|
|
||||||
// TriggersListCmd lists triggers for a function.
|
|
||||||
var TriggersListCmd = &cobra.Command{
|
|
||||||
Use: "list <function-name>",
|
|
||||||
Short: "List triggers for a function",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runTriggersList,
|
|
||||||
}
|
|
||||||
|
|
||||||
// TriggersDeleteCmd deletes a trigger.
|
|
||||||
var TriggersDeleteCmd = &cobra.Command{
|
|
||||||
Use: "delete <function-name> <trigger-id>",
|
|
||||||
Short: "Delete a trigger",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: runTriggersDelete,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
TriggersCmd.AddCommand(TriggersAddCmd)
|
|
||||||
TriggersCmd.AddCommand(TriggersListCmd)
|
|
||||||
TriggersCmd.AddCommand(TriggersDeleteCmd)
|
|
||||||
|
|
||||||
TriggersAddCmd.Flags().StringVar(&triggerTopic, "topic", "", "PubSub topic to trigger on (required)")
|
|
||||||
TriggersAddCmd.MarkFlagRequired("topic")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runTriggersAdd(cmd *cobra.Command, args []string) error {
|
|
||||||
funcName := args[0]
|
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
|
||||||
"topic": triggerTopic,
|
|
||||||
})
|
|
||||||
|
|
||||||
resp, err := apiRequest("POST", "/v1/functions/"+funcName+"/triggers", bytes.NewReader(body), "application/json")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode != 201 && resp.StatusCode != 200 {
|
|
||||||
return fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
||||||
return fmt.Errorf("failed to parse response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Trigger added: %s → %s (id: %s)\n", triggerTopic, funcName, result["trigger_id"])
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runTriggersList(cmd *cobra.Command, args []string) error {
|
|
||||||
funcName := args[0]
|
|
||||||
|
|
||||||
result, err := apiGet("/v1/functions/" + funcName + "/triggers")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
triggers, _ := result["triggers"].([]interface{})
|
|
||||||
if len(triggers) == 0 {
|
|
||||||
fmt.Printf("No triggers for function %q.\n", funcName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
|
|
||||||
fmt.Fprintln(w, "ID\tTOPIC\tENABLED")
|
|
||||||
for _, t := range triggers {
|
|
||||||
tr, ok := t.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id, _ := tr["ID"].(string)
|
|
||||||
if id == "" {
|
|
||||||
id, _ = tr["id"].(string)
|
|
||||||
}
|
|
||||||
topic, _ := tr["Topic"].(string)
|
|
||||||
if topic == "" {
|
|
||||||
topic, _ = tr["topic"].(string)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
w.Flush()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runTriggersDelete(cmd *cobra.Command, args []string) error {
|
|
||||||
funcName := args[0]
|
|
||||||
triggerID := args[1]
|
|
||||||
|
|
||||||
result, err := apiDelete("/v1/functions/" + funcName + "/triggers/" + triggerID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg, ok := result["message"]; ok {
|
|
||||||
fmt.Println(msg)
|
|
||||||
} else {
|
|
||||||
fmt.Println("Trigger deleted.")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
package functions
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"text/tabwriter"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// VersionsCmd lists all versions of a function.
|
|
||||||
var VersionsCmd = &cobra.Command{
|
|
||||||
Use: "versions <name>",
|
|
||||||
Short: "List all versions of a function",
|
|
||||||
Long: "Shows all deployed versions of a specific function.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runVersions,
|
|
||||||
}
|
|
||||||
|
|
||||||
func runVersions(cmd *cobra.Command, args []string) error {
|
|
||||||
name := args[0]
|
|
||||||
|
|
||||||
result, err := apiGet("/v1/functions/" + name + "/versions")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
versions, ok := result["versions"].([]interface{})
|
|
||||||
if !ok || len(versions) == 0 {
|
|
||||||
fmt.Printf("No versions found for function %q.\n", name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
||||||
fmt.Fprintln(w, "VERSION\tWASM CID\tSTATUS\tCREATED")
|
|
||||||
fmt.Fprintln(w, "-------\t--------\t------\t-------")
|
|
||||||
|
|
||||||
for _, v := range versions {
|
|
||||||
ver, ok := v.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
version := valNum(ver, "version")
|
|
||||||
wasmCID := valStr(ver, "wasm_cid")
|
|
||||||
status := valStr(ver, "status")
|
|
||||||
created := valStr(ver, "created_at")
|
|
||||||
|
|
||||||
fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", version, wasmCID, status, created)
|
|
||||||
}
|
|
||||||
w.Flush()
|
|
||||||
|
|
||||||
fmt.Printf("\nTotal: %d version(s)\n", len(versions))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,903 +0,0 @@
|
|||||||
package monitor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/production/report"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AlertSeverity represents the severity of an alert.
|
|
||||||
type AlertSeverity string
|
|
||||||
|
|
||||||
const (
|
|
||||||
AlertCritical AlertSeverity = "critical"
|
|
||||||
AlertWarning AlertSeverity = "warning"
|
|
||||||
AlertInfo AlertSeverity = "info"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Alert represents a detected issue.
|
|
||||||
type Alert struct {
|
|
||||||
Severity AlertSeverity `json:"severity"`
|
|
||||||
Subsystem string `json:"subsystem"`
|
|
||||||
Node string `json:"node"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// joiningGraceSec is the grace period (in seconds) after a node starts during
|
|
||||||
// which unreachability alerts from other nodes are downgraded to info.
|
|
||||||
const joiningGraceSec = 300
|
|
||||||
|
|
||||||
// nodeContext carries per-node metadata needed for context-aware alerting.
|
|
||||||
type nodeContext struct {
|
|
||||||
host string
|
|
||||||
role string // "node", "nameserver-ns1", etc.
|
|
||||||
isNameserver bool
|
|
||||||
isJoining bool // orama-node active_since_sec < joiningGraceSec
|
|
||||||
uptimeSec int // orama-node active_since_sec
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildNodeContexts builds a map of WG IP -> nodeContext for all healthy nodes.
|
|
||||||
func buildNodeContexts(snap *ClusterSnapshot) map[string]*nodeContext {
|
|
||||||
ctxMap := make(map[string]*nodeContext)
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Report == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r := cs.Report
|
|
||||||
host := nodeHost(r)
|
|
||||||
|
|
||||||
nc := &nodeContext{
|
|
||||||
host: host,
|
|
||||||
role: cs.Node.Role,
|
|
||||||
isNameserver: strings.HasPrefix(cs.Node.Role, "nameserver"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine uptime from orama-node service
|
|
||||||
if r.Services != nil {
|
|
||||||
for _, svc := range r.Services.Services {
|
|
||||||
if svc.Name == "orama-node" && svc.ActiveState == "active" {
|
|
||||||
nc.uptimeSec = int(svc.ActiveSinceSec)
|
|
||||||
nc.isJoining = svc.ActiveSinceSec < joiningGraceSec
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctxMap[host] = nc
|
|
||||||
// Also index by WG IP for cross-node RQLite unreachability lookups
|
|
||||||
if r.WireGuard != nil && r.WireGuard.WgIP != "" {
|
|
||||||
ctxMap[r.WireGuard.WgIP] = nc
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ctxMap
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeriveAlerts scans a ClusterSnapshot and produces alerts.
|
|
||||||
func DeriveAlerts(snap *ClusterSnapshot) []Alert {
|
|
||||||
var alerts []Alert
|
|
||||||
|
|
||||||
// Collection failures
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Error != nil {
|
|
||||||
alerts = append(alerts, Alert{
|
|
||||||
Severity: AlertCritical,
|
|
||||||
Subsystem: "ssh",
|
|
||||||
Node: cs.Node.Host,
|
|
||||||
Message: fmt.Sprintf("Collection failed: %v", cs.Error),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reports := snap.Healthy()
|
|
||||||
if len(reports) == 0 {
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build context map for role/uptime-aware alerting
|
|
||||||
nodeCtxMap := buildNodeContexts(snap)
|
|
||||||
|
|
||||||
// Cross-node checks
|
|
||||||
alerts = append(alerts, checkRQLiteLeader(reports)...)
|
|
||||||
alerts = append(alerts, checkRQLiteQuorum(reports)...)
|
|
||||||
alerts = append(alerts, checkRaftTermConsistency(reports)...)
|
|
||||||
alerts = append(alerts, checkAppliedIndexLag(reports)...)
|
|
||||||
alerts = append(alerts, checkWGPeerSymmetry(reports)...)
|
|
||||||
alerts = append(alerts, checkClockSkew(reports)...)
|
|
||||||
alerts = append(alerts, checkBinaryVersion(reports)...)
|
|
||||||
alerts = append(alerts, checkOlricMemberConsistency(reports)...)
|
|
||||||
alerts = append(alerts, checkIPFSSwarmConsistency(reports)...)
|
|
||||||
alerts = append(alerts, checkIPFSClusterConsistency(reports)...)
|
|
||||||
|
|
||||||
// Per-node checks
|
|
||||||
for _, r := range reports {
|
|
||||||
host := nodeHost(r)
|
|
||||||
nc := nodeCtxMap[host]
|
|
||||||
alerts = append(alerts, checkNodeRQLite(r, host, nodeCtxMap)...)
|
|
||||||
alerts = append(alerts, checkNodeWireGuard(r, host)...)
|
|
||||||
alerts = append(alerts, checkNodeSystem(r, host)...)
|
|
||||||
alerts = append(alerts, checkNodeServices(r, host, nc)...)
|
|
||||||
alerts = append(alerts, checkNodeDNS(r, host, nc)...)
|
|
||||||
alerts = append(alerts, checkNodeAnyone(r, host)...)
|
|
||||||
alerts = append(alerts, checkNodeProcesses(r, host)...)
|
|
||||||
alerts = append(alerts, checkNodeNamespaces(r, host)...)
|
|
||||||
alerts = append(alerts, checkNodeNetwork(r, host)...)
|
|
||||||
alerts = append(alerts, checkNodeOlric(r, host)...)
|
|
||||||
alerts = append(alerts, checkNodeIPFS(r, host)...)
|
|
||||||
alerts = append(alerts, checkNodeGateway(r, host)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func nodeHost(r *report.NodeReport) string {
|
|
||||||
if r.PublicIP != "" {
|
|
||||||
return r.PublicIP
|
|
||||||
}
|
|
||||||
return r.Hostname
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Cross-node checks
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func checkRQLiteLeader(reports []*report.NodeReport) []Alert {
|
|
||||||
var alerts []Alert
|
|
||||||
leaders := 0
|
|
||||||
leaderAddrs := map[string]bool{}
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.RQLite != nil && r.RQLite.RaftState == "Leader" {
|
|
||||||
leaders++
|
|
||||||
}
|
|
||||||
if r.RQLite != nil && r.RQLite.LeaderAddr != "" {
|
|
||||||
leaderAddrs[r.RQLite.LeaderAddr] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if leaders == 0 {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "rqlite", "cluster", "No RQLite leader found"})
|
|
||||||
} else if leaders > 1 {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "rqlite", "cluster",
|
|
||||||
fmt.Sprintf("Split brain: %d leaders detected", leaders)})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(leaderAddrs) > 1 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "rqlite", "cluster",
|
|
||||||
fmt.Sprintf("Leader disagreement: nodes report %d different leader addresses", len(leaderAddrs))})
|
|
||||||
}
|
|
||||||
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkRQLiteQuorum(reports []*report.NodeReport) []Alert {
|
|
||||||
var voters, responsive int
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.RQLite == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r.RQLite.Responsive {
|
|
||||||
responsive++
|
|
||||||
if r.RQLite.Voter {
|
|
||||||
voters++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if responsive == 0 {
|
|
||||||
return nil // no rqlite data at all
|
|
||||||
}
|
|
||||||
|
|
||||||
// Total voters = responsive voters + unresponsive nodes that should be voters.
|
|
||||||
// For quorum calculation, use the total voter count (responsive + unreachable).
|
|
||||||
totalVoters := voters
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.RQLite != nil && !r.RQLite.Responsive {
|
|
||||||
// Assume unresponsive nodes were voters (conservative estimate).
|
|
||||||
totalVoters++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if totalVoters < 2 {
|
|
||||||
return nil // single-node cluster, no quorum concept
|
|
||||||
}
|
|
||||||
|
|
||||||
quorum := totalVoters/2 + 1
|
|
||||||
if voters < quorum {
|
|
||||||
return []Alert{{AlertCritical, "rqlite", "cluster",
|
|
||||||
fmt.Sprintf("Quorum lost: only %d/%d voters reachable (need %d)", voters, totalVoters, quorum)}}
|
|
||||||
}
|
|
||||||
if voters == quorum {
|
|
||||||
return []Alert{{AlertWarning, "rqlite", "cluster",
|
|
||||||
fmt.Sprintf("Quorum fragile: exactly %d/%d voters reachable (one more failure = quorum loss)", voters, totalVoters)}}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkRaftTermConsistency(reports []*report.NodeReport) []Alert {
|
|
||||||
var minTerm, maxTerm uint64
|
|
||||||
first := true
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.RQLite == nil || !r.RQLite.Responsive {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if first {
|
|
||||||
minTerm = r.RQLite.Term
|
|
||||||
maxTerm = r.RQLite.Term
|
|
||||||
first = false
|
|
||||||
}
|
|
||||||
if r.RQLite.Term < minTerm {
|
|
||||||
minTerm = r.RQLite.Term
|
|
||||||
}
|
|
||||||
if r.RQLite.Term > maxTerm {
|
|
||||||
maxTerm = r.RQLite.Term
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if maxTerm-minTerm > 1 {
|
|
||||||
return []Alert{{AlertWarning, "rqlite", "cluster",
|
|
||||||
fmt.Sprintf("Raft term inconsistency: min=%d, max=%d (delta=%d)", minTerm, maxTerm, maxTerm-minTerm)}}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkAppliedIndexLag(reports []*report.NodeReport) []Alert {
|
|
||||||
var maxApplied uint64
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.RQLite != nil && r.RQLite.Applied > maxApplied {
|
|
||||||
maxApplied = r.RQLite.Applied
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var alerts []Alert
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.RQLite == nil || !r.RQLite.Responsive {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lag := maxApplied - r.RQLite.Applied
|
|
||||||
if lag > 100 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "rqlite", nodeHost(r),
|
|
||||||
fmt.Sprintf("Applied index lag: %d behind leader (local=%d, max=%d)", lag, r.RQLite.Applied, maxApplied)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkWGPeerSymmetry(reports []*report.NodeReport) []Alert {
|
|
||||||
type nodeInfo struct {
|
|
||||||
host string
|
|
||||||
peerKeys map[string]bool
|
|
||||||
}
|
|
||||||
var nodes []nodeInfo
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.WireGuard == nil || !r.WireGuard.InterfaceUp {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ni := nodeInfo{host: nodeHost(r), peerKeys: map[string]bool{}}
|
|
||||||
for _, p := range r.WireGuard.Peers {
|
|
||||||
ni.peerKeys[p.PublicKey] = true
|
|
||||||
}
|
|
||||||
nodes = append(nodes, ni)
|
|
||||||
}
|
|
||||||
|
|
||||||
var alerts []Alert
|
|
||||||
expectedPeers := len(nodes) - 1
|
|
||||||
for _, ni := range nodes {
|
|
||||||
if len(ni.peerKeys) < expectedPeers {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "wireguard", ni.host,
|
|
||||||
fmt.Sprintf("WG peer count mismatch: has %d peers, expected %d", len(ni.peerKeys), expectedPeers)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkClockSkew(reports []*report.NodeReport) []Alert {
|
|
||||||
var times []struct {
|
|
||||||
host string
|
|
||||||
t int64
|
|
||||||
}
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.System != nil && r.System.TimeUnix > 0 {
|
|
||||||
times = append(times, struct {
|
|
||||||
host string
|
|
||||||
t int64
|
|
||||||
}{nodeHost(r), r.System.TimeUnix})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(times) < 2 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var minT, maxT int64 = times[0].t, times[0].t
|
|
||||||
var minHost, maxHost string = times[0].host, times[0].host
|
|
||||||
for _, t := range times[1:] {
|
|
||||||
if t.t < minT {
|
|
||||||
minT = t.t
|
|
||||||
minHost = t.host
|
|
||||||
}
|
|
||||||
if t.t > maxT {
|
|
||||||
maxT = t.t
|
|
||||||
maxHost = t.host
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
delta := maxT - minT
|
|
||||||
if delta > 5 {
|
|
||||||
return []Alert{{AlertWarning, "system", "cluster",
|
|
||||||
fmt.Sprintf("Clock skew: %ds between %s and %s", delta, minHost, maxHost)}}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkBinaryVersion(reports []*report.NodeReport) []Alert {
|
|
||||||
versions := map[string][]string{} // version -> list of hosts
|
|
||||||
for _, r := range reports {
|
|
||||||
v := r.Version
|
|
||||||
if v == "" {
|
|
||||||
v = "unknown"
|
|
||||||
}
|
|
||||||
versions[v] = append(versions[v], nodeHost(r))
|
|
||||||
}
|
|
||||||
if len(versions) > 1 {
|
|
||||||
msg := "Binary version mismatch:"
|
|
||||||
for v, hosts := range versions {
|
|
||||||
msg += fmt.Sprintf(" %s=%v", v, hosts)
|
|
||||||
}
|
|
||||||
return []Alert{{AlertWarning, "system", "cluster", msg}}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkOlricMemberConsistency(reports []*report.NodeReport) []Alert {
|
|
||||||
// Count nodes where Olric is active to determine expected member count.
|
|
||||||
activeCount := 0
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.Olric != nil && r.Olric.ServiceActive {
|
|
||||||
activeCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if activeCount < 2 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var alerts []Alert
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.Olric == nil || !r.Olric.ServiceActive || r.Olric.MemberCount == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r.Olric.MemberCount < activeCount {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "olric", nodeHost(r),
|
|
||||||
fmt.Sprintf("Olric member count: %d (expected %d active nodes)", r.Olric.MemberCount, activeCount)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkIPFSSwarmConsistency(reports []*report.NodeReport) []Alert {
|
|
||||||
// Count IPFS-active nodes to determine expected peer count.
|
|
||||||
activeCount := 0
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.IPFS != nil && r.IPFS.DaemonActive {
|
|
||||||
activeCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if activeCount < 2 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedPeers := activeCount - 1
|
|
||||||
var alerts []Alert
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.IPFS == nil || !r.IPFS.DaemonActive {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r.IPFS.SwarmPeerCount == 0 {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "ipfs", nodeHost(r),
|
|
||||||
"IPFS node isolated: 0 swarm peers"})
|
|
||||||
} else if r.IPFS.SwarmPeerCount < expectedPeers {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "ipfs", nodeHost(r),
|
|
||||||
fmt.Sprintf("IPFS swarm peers: %d (expected %d)", r.IPFS.SwarmPeerCount, expectedPeers)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkIPFSClusterConsistency(reports []*report.NodeReport) []Alert {
|
|
||||||
activeCount := 0
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.IPFS != nil && r.IPFS.ClusterActive {
|
|
||||||
activeCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if activeCount < 2 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var alerts []Alert
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.IPFS == nil || !r.IPFS.ClusterActive {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if r.IPFS.ClusterPeerCount < activeCount {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "ipfs", nodeHost(r),
|
|
||||||
fmt.Sprintf("IPFS cluster peers: %d (expected %d)", r.IPFS.ClusterPeerCount, activeCount)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Per-node checks
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func checkNodeRQLite(r *report.NodeReport, host string, nodeCtxMap map[string]*nodeContext) []Alert {
|
|
||||||
if r.RQLite == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var alerts []Alert
|
|
||||||
|
|
||||||
if !r.RQLite.Responsive {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "rqlite", host, "RQLite not responding"})
|
|
||||||
return alerts // no point checking further
|
|
||||||
}
|
|
||||||
|
|
||||||
if !r.RQLite.Ready {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "rqlite", host, "RQLite not ready (/readyz failed)"})
|
|
||||||
}
|
|
||||||
if !r.RQLite.StrongRead {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "rqlite", host, "Strong read failed"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Raft state anomalies
|
|
||||||
if r.RQLite.RaftState == "Candidate" {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "rqlite", host, "RQLite in election (Candidate state)"})
|
|
||||||
}
|
|
||||||
if r.RQLite.RaftState == "Shutdown" {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "rqlite", host, "RQLite in Shutdown state"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// FSM backlog
|
|
||||||
if r.RQLite.FsmPending > 10 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "rqlite", host,
|
|
||||||
fmt.Sprintf("RQLite FSM backlog: %d entries pending", r.RQLite.FsmPending)})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit-applied gap (per-node, distinct from cross-node applied index lag)
|
|
||||||
if r.RQLite.Commit > 0 && r.RQLite.Applied > 0 && r.RQLite.Commit > r.RQLite.Applied {
|
|
||||||
gap := r.RQLite.Commit - r.RQLite.Applied
|
|
||||||
if gap > 100 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "rqlite", host,
|
|
||||||
fmt.Sprintf("RQLite commit-applied gap: %d (commit=%d, applied=%d)", gap, r.RQLite.Commit, r.RQLite.Applied)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resource pressure
|
|
||||||
if r.RQLite.Goroutines > 1000 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "rqlite", host,
|
|
||||||
fmt.Sprintf("RQLite goroutine count high: %d", r.RQLite.Goroutines)})
|
|
||||||
}
|
|
||||||
if r.RQLite.HeapMB > 1000 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "rqlite", host,
|
|
||||||
fmt.Sprintf("RQLite heap memory high: %dMB", r.RQLite.HeapMB)})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cluster partition detection: check if this node reports other nodes as unreachable.
|
|
||||||
// If the unreachable node recently joined (< 5 min), downgrade to info — probes
|
|
||||||
// may not have succeeded yet and this is expected transient behavior.
|
|
||||||
for nodeAddr, info := range r.RQLite.Nodes {
|
|
||||||
if !info.Reachable {
|
|
||||||
// nodeAddr is like "10.0.0.4:7001" — extract the IP to look up context
|
|
||||||
targetIP := strings.Split(nodeAddr, ":")[0]
|
|
||||||
if targetCtx, ok := nodeCtxMap[targetIP]; ok && targetCtx.isJoining {
|
|
||||||
alerts = append(alerts, Alert{AlertInfo, "rqlite", host,
|
|
||||||
fmt.Sprintf("Node %s recently joined (%ds ago), probe pending for %s",
|
|
||||||
targetCtx.host, targetCtx.uptimeSec, nodeAddr)})
|
|
||||||
} else {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "rqlite", host,
|
|
||||||
fmt.Sprintf("RQLite reports node %s unreachable (cluster partition)", nodeAddr)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug vars
|
|
||||||
if dv := r.RQLite.DebugVars; dv != nil {
|
|
||||||
if dv.LeaderNotFound > 0 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "rqlite", host,
|
|
||||||
fmt.Sprintf("RQLite leader_not_found errors: %d", dv.LeaderNotFound)})
|
|
||||||
}
|
|
||||||
if dv.SnapshotErrors > 0 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "rqlite", host,
|
|
||||||
fmt.Sprintf("RQLite snapshot errors: %d", dv.SnapshotErrors)})
|
|
||||||
}
|
|
||||||
totalQueryErrors := dv.QueryErrors + dv.ExecuteErrors
|
|
||||||
if totalQueryErrors > 0 {
|
|
||||||
alerts = append(alerts, Alert{AlertInfo, "rqlite", host,
|
|
||||||
fmt.Sprintf("RQLite query/execute errors: %d", totalQueryErrors)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkNodeWireGuard(r *report.NodeReport, host string) []Alert {
|
|
||||||
if r.WireGuard == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var alerts []Alert
|
|
||||||
if !r.WireGuard.InterfaceUp {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "wireguard", host, "WireGuard interface down"})
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
for _, p := range r.WireGuard.Peers {
|
|
||||||
if p.HandshakeAgeSec > 180 && p.LatestHandshake > 0 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "wireguard", host,
|
|
||||||
fmt.Sprintf("Stale WG handshake with peer %s: %ds ago", truncateKey(p.PublicKey), p.HandshakeAgeSec)})
|
|
||||||
}
|
|
||||||
if p.LatestHandshake == 0 {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "wireguard", host,
|
|
||||||
fmt.Sprintf("WG peer %s has never handshaked", truncateKey(p.PublicKey))})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkNodeSystem(r *report.NodeReport, host string) []Alert {
|
|
||||||
if r.System == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var alerts []Alert
|
|
||||||
if r.System.MemUsePct > 90 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "system", host,
|
|
||||||
fmt.Sprintf("Memory at %d%%", r.System.MemUsePct)})
|
|
||||||
}
|
|
||||||
if r.System.DiskUsePct > 85 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "system", host,
|
|
||||||
fmt.Sprintf("Disk at %d%%", r.System.DiskUsePct)})
|
|
||||||
}
|
|
||||||
if r.System.OOMKills > 0 {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "system", host,
|
|
||||||
fmt.Sprintf("%d OOM kills detected", r.System.OOMKills)})
|
|
||||||
}
|
|
||||||
if r.System.SwapUsedMB > 0 && r.System.SwapTotalMB > 0 {
|
|
||||||
pct := r.System.SwapUsedMB * 100 / r.System.SwapTotalMB
|
|
||||||
if pct > 30 {
|
|
||||||
alerts = append(alerts, Alert{AlertInfo, "system", host,
|
|
||||||
fmt.Sprintf("Swap usage at %d%%", pct)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// High load
|
|
||||||
if r.System.CPUCount > 0 {
|
|
||||||
loadRatio := r.System.LoadAvg1 / float64(r.System.CPUCount)
|
|
||||||
if loadRatio > 2.0 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "system", host,
|
|
||||||
fmt.Sprintf("High load: %.1f (%.1fx CPU count)", r.System.LoadAvg1, loadRatio)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Inode exhaustion
|
|
||||||
if r.System.InodePct > 95 {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "system", host,
|
|
||||||
fmt.Sprintf("Inode exhaustion imminent: %d%%", r.System.InodePct)})
|
|
||||||
} else if r.System.InodePct > 90 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "system", host,
|
|
||||||
fmt.Sprintf("Inode usage at %d%%", r.System.InodePct)})
|
|
||||||
}
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkNodeServices(r *report.NodeReport, host string, nc *nodeContext) []Alert {
|
|
||||||
if r.Services == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var alerts []Alert
|
|
||||||
for _, svc := range r.Services.Services {
|
|
||||||
// Skip services that are expected to be inactive based on node role/mode
|
|
||||||
if shouldSkipServiceAlert(svc.Name, svc.ActiveState, r, nc) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if svc.ActiveState == "failed" {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "service", host,
|
|
||||||
fmt.Sprintf("Service %s is FAILED", svc.Name)})
|
|
||||||
} else if svc.ActiveState != "active" && svc.ActiveState != "" && svc.ActiveState != "unknown" {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "service", host,
|
|
||||||
fmt.Sprintf("Service %s is %s", svc.Name, svc.ActiveState)})
|
|
||||||
}
|
|
||||||
if svc.RestartLoopRisk {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "service", host,
|
|
||||||
fmt.Sprintf("Service %s restart loop: %d restarts, active for %ds", svc.Name, svc.NRestarts, svc.ActiveSinceSec)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, unit := range r.Services.FailedUnits {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "service", host,
|
|
||||||
fmt.Sprintf("Failed systemd unit: %s", unit)})
|
|
||||||
}
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
// shouldSkipServiceAlert returns true if this service being inactive is expected
|
|
||||||
// given the node's role and anyone mode.
|
|
||||||
func shouldSkipServiceAlert(svcName, state string, r *report.NodeReport, nc *nodeContext) bool {
|
|
||||||
if state == "active" || state == "failed" {
|
|
||||||
return false // always report active (no alert) and failed (always alert)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CoreDNS: only expected on nameserver nodes
|
|
||||||
if svcName == "coredns" && (nc == nil || !nc.isNameserver) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anyone services: only alert for the mode the node is configured for
|
|
||||||
if r.Anyone != nil {
|
|
||||||
mode := r.Anyone.Mode
|
|
||||||
if svcName == "orama-anyone-client" && mode == "relay" {
|
|
||||||
return true // relay node doesn't run client
|
|
||||||
}
|
|
||||||
if svcName == "orama-anyone-relay" && mode == "client" {
|
|
||||||
return true // client node doesn't run relay
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If anyone section is nil (no anyone configured), skip both anyone services
|
|
||||||
if r.Anyone == nil && (svcName == "orama-anyone-client" || svcName == "orama-anyone-relay") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkNodeDNS(r *report.NodeReport, host string, nc *nodeContext) []Alert {
|
|
||||||
if r.DNS == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
isNameserver := nc != nil && nc.isNameserver
|
|
||||||
|
|
||||||
var alerts []Alert
|
|
||||||
|
|
||||||
// CoreDNS: only check on nameserver nodes
|
|
||||||
if isNameserver && !r.DNS.CoreDNSActive {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "dns", host, "CoreDNS is down"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Caddy: check on all nodes (any node can host namespaces)
|
|
||||||
if !r.DNS.CaddyActive {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "dns", host, "Caddy is down"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TLS cert expiry: only meaningful on nameserver nodes that have public domains
|
|
||||||
if isNameserver {
|
|
||||||
if r.DNS.BaseTLSDaysLeft >= 0 && r.DNS.BaseTLSDaysLeft < 14 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "dns", host,
|
|
||||||
fmt.Sprintf("Base TLS cert expires in %d days", r.DNS.BaseTLSDaysLeft)})
|
|
||||||
}
|
|
||||||
if r.DNS.WildTLSDaysLeft >= 0 && r.DNS.WildTLSDaysLeft < 14 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "dns", host,
|
|
||||||
fmt.Sprintf("Wildcard TLS cert expires in %d days", r.DNS.WildTLSDaysLeft)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DNS resolution checks: only on nameserver nodes with CoreDNS running
|
|
||||||
if isNameserver && r.DNS.CoreDNSActive {
|
|
||||||
if !r.DNS.SOAResolves {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "dns", host, "SOA record not resolving"})
|
|
||||||
}
|
|
||||||
if !r.DNS.WildcardResolves {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "dns", host, "Wildcard DNS not resolving"})
|
|
||||||
}
|
|
||||||
if !r.DNS.BaseAResolves {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "dns", host, "Base domain A record not resolving"})
|
|
||||||
}
|
|
||||||
if !r.DNS.NSResolves {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "dns", host, "NS records not resolving"})
|
|
||||||
}
|
|
||||||
if !r.DNS.Port53Bound {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "dns", host, "CoreDNS active but port 53 not bound"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.DNS.CaddyActive && !r.DNS.Port443Bound {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "dns", host, "Caddy active but port 443 not bound"})
|
|
||||||
}
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkNodeAnyone(r *report.NodeReport, host string) []Alert {
|
|
||||||
if r.Anyone == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var alerts []Alert
|
|
||||||
if (r.Anyone.RelayActive || r.Anyone.ClientActive) && !r.Anyone.Bootstrapped {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "anyone", host,
|
|
||||||
fmt.Sprintf("Anyone bootstrap at %d%%", r.Anyone.BootstrapPct)})
|
|
||||||
}
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkNodeProcesses(r *report.NodeReport, host string) []Alert {
|
|
||||||
if r.Processes == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var alerts []Alert
|
|
||||||
if r.Processes.ZombieCount > 0 {
|
|
||||||
alerts = append(alerts, Alert{AlertInfo, "system", host,
|
|
||||||
fmt.Sprintf("%d zombie processes", r.Processes.ZombieCount)})
|
|
||||||
}
|
|
||||||
if r.Processes.OrphanCount > 0 {
|
|
||||||
alerts = append(alerts, Alert{AlertInfo, "system", host,
|
|
||||||
fmt.Sprintf("%d orphan orama processes", r.Processes.OrphanCount)})
|
|
||||||
}
|
|
||||||
if r.Processes.PanicCount > 0 {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "system", host,
|
|
||||||
fmt.Sprintf("%d panic/fatal in orama-node logs (1h)", r.Processes.PanicCount)})
|
|
||||||
}
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkNodeNamespaces(r *report.NodeReport, host string) []Alert {
|
|
||||||
var alerts []Alert
|
|
||||||
for _, ns := range r.Namespaces {
|
|
||||||
if !ns.GatewayUp {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "namespace", host,
|
|
||||||
fmt.Sprintf("Namespace %s gateway down", ns.Name)})
|
|
||||||
}
|
|
||||||
if !ns.RQLiteUp {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "namespace", host,
|
|
||||||
fmt.Sprintf("Namespace %s RQLite down", ns.Name)})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkNodeNetwork(r *report.NodeReport, host string) []Alert {
|
|
||||||
if r.Network == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var alerts []Alert
|
|
||||||
if !r.Network.UFWActive {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "network", host, "UFW firewall is inactive"})
|
|
||||||
}
|
|
||||||
if !r.Network.InternetReachable {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "network", host, "Internet not reachable (ping 8.8.8.8 failed)"})
|
|
||||||
}
|
|
||||||
if r.Network.TCPRetransRate > 5.0 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "network", host,
|
|
||||||
fmt.Sprintf("High TCP retransmission rate: %.1f%%", r.Network.TCPRetransRate)})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for internal ports exposed in UFW rules.
|
|
||||||
// Ports 5001 (RQLite), 6001 (Gateway), 3320 (Olric), 4501 (IPFS API) should be internal only.
|
|
||||||
internalPorts := []string{"5001", "6001", "3320", "4501"}
|
|
||||||
for _, rule := range r.Network.UFWRules {
|
|
||||||
ruleLower := strings.ToLower(rule)
|
|
||||||
// Only flag ALLOW rules (not deny/reject).
|
|
||||||
if !strings.Contains(ruleLower, "allow") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, port := range internalPorts {
|
|
||||||
// Match rules like "5001 ALLOW Anywhere" or "5001/tcp ALLOW IN"
|
|
||||||
// but not rules restricted to 10.0.0.0/24 (WG subnet).
|
|
||||||
if strings.Contains(rule, port) && !strings.Contains(rule, "10.0.0.") {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "network", host,
|
|
||||||
fmt.Sprintf("Internal port %s exposed in UFW: %s", port, strings.TrimSpace(rule))})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkNodeOlric(r *report.NodeReport, host string) []Alert {
|
|
||||||
if r.Olric == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var alerts []Alert
|
|
||||||
|
|
||||||
if !r.Olric.ServiceActive {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "olric", host, "Olric service down"})
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
if !r.Olric.MemberlistUp {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "olric", host, "Olric memberlist port down"})
|
|
||||||
}
|
|
||||||
if r.Olric.LogSuspects > 0 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "olric", host,
|
|
||||||
fmt.Sprintf("Olric member suspects: %d in last hour", r.Olric.LogSuspects)})
|
|
||||||
}
|
|
||||||
if r.Olric.LogFlapping > 5 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "olric", host,
|
|
||||||
fmt.Sprintf("Olric members flapping: %d join/leave events in last hour", r.Olric.LogFlapping)})
|
|
||||||
}
|
|
||||||
if r.Olric.LogErrors > 20 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "olric", host,
|
|
||||||
fmt.Sprintf("High Olric error rate: %d errors in last hour", r.Olric.LogErrors)})
|
|
||||||
}
|
|
||||||
if r.Olric.RestartCount > 3 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "olric", host,
|
|
||||||
fmt.Sprintf("Olric excessive restarts: %d", r.Olric.RestartCount)})
|
|
||||||
}
|
|
||||||
if r.Olric.ProcessMemMB > 500 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "olric", host,
|
|
||||||
fmt.Sprintf("Olric high memory: %dMB", r.Olric.ProcessMemMB)})
|
|
||||||
}
|
|
||||||
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkNodeIPFS(r *report.NodeReport, host string) []Alert {
|
|
||||||
if r.IPFS == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var alerts []Alert
|
|
||||||
|
|
||||||
if !r.IPFS.DaemonActive {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "ipfs", host, "IPFS daemon down"})
|
|
||||||
}
|
|
||||||
if !r.IPFS.ClusterActive {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "ipfs", host, "IPFS cluster down"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only check these if daemon is running (otherwise data is meaningless).
|
|
||||||
if r.IPFS.DaemonActive {
|
|
||||||
if r.IPFS.SwarmPeerCount == 0 {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "ipfs", host, "IPFS isolated: no swarm peers"})
|
|
||||||
}
|
|
||||||
if !r.IPFS.HasSwarmKey {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "ipfs", host,
|
|
||||||
"IPFS swarm key missing (private network compromised)"})
|
|
||||||
}
|
|
||||||
if !r.IPFS.BootstrapEmpty {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "ipfs", host,
|
|
||||||
"IPFS bootstrap list not empty (should be empty for private swarm)"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.IPFS.RepoUsePct > 95 {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "ipfs", host,
|
|
||||||
fmt.Sprintf("IPFS repo nearly full: %d%%", r.IPFS.RepoUsePct)})
|
|
||||||
} else if r.IPFS.RepoUsePct > 90 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "ipfs", host,
|
|
||||||
fmt.Sprintf("IPFS repo at %d%%", r.IPFS.RepoUsePct)})
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.IPFS.ClusterErrors > 0 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "ipfs", host,
|
|
||||||
fmt.Sprintf("IPFS cluster peer errors: %d", r.IPFS.ClusterErrors)})
|
|
||||||
}
|
|
||||||
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkNodeGateway(r *report.NodeReport, host string) []Alert {
|
|
||||||
if r.Gateway == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var alerts []Alert
|
|
||||||
|
|
||||||
if !r.Gateway.Responsive {
|
|
||||||
alerts = append(alerts, Alert{AlertCritical, "gateway", host, "Gateway not responding"})
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Gateway.HTTPStatus != 200 {
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "gateway", host,
|
|
||||||
fmt.Sprintf("Gateway health check returned HTTP %d", r.Gateway.HTTPStatus)})
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, sub := range r.Gateway.Subsystems {
|
|
||||||
if sub.Status != "ok" && sub.Status != "" {
|
|
||||||
msg := fmt.Sprintf("Gateway subsystem %s: status=%s", name, sub.Status)
|
|
||||||
if sub.Error != "" {
|
|
||||||
msg += fmt.Sprintf(" error=%s", sub.Error)
|
|
||||||
}
|
|
||||||
alerts = append(alerts, Alert{AlertWarning, "gateway", host, msg})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return alerts
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncateKey(key string) string {
|
|
||||||
if len(key) > 8 {
|
|
||||||
return key[:8] + "..."
|
|
||||||
}
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
package monitor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/production/report"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/sandbox"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CollectorConfig holds configuration for the collection pipeline.
|
|
||||||
type CollectorConfig struct {
|
|
||||||
ConfigPath string
|
|
||||||
Env string
|
|
||||||
NodeFilter string
|
|
||||||
Timeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// CollectOnce runs `sudo orama node report --json` on all matching nodes
|
|
||||||
// in parallel and returns a ClusterSnapshot.
|
|
||||||
func CollectOnce(ctx context.Context, cfg CollectorConfig) (*ClusterSnapshot, error) {
|
|
||||||
nodes, cleanup, err := loadNodes(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
timeout := cfg.Timeout
|
|
||||||
if timeout == 0 {
|
|
||||||
timeout = 30 * time.Second
|
|
||||||
}
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
snap := &ClusterSnapshot{
|
|
||||||
Environment: cfg.Env,
|
|
||||||
CollectedAt: start,
|
|
||||||
Nodes: make([]CollectionStatus, len(nodes)),
|
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for i, node := range nodes {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(idx int, n inspector.Node) {
|
|
||||||
defer wg.Done()
|
|
||||||
snap.Nodes[idx] = collectNodeReport(ctx, n, timeout)
|
|
||||||
}(i, node)
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
snap.Duration = time.Since(start)
|
|
||||||
snap.Alerts = DeriveAlerts(snap)
|
|
||||||
|
|
||||||
return snap, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectNodeReport SSHes into a single node and parses the JSON report.
|
|
||||||
func collectNodeReport(ctx context.Context, node inspector.Node, timeout time.Duration) CollectionStatus {
|
|
||||||
nodeCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
result := inspector.RunSSH(nodeCtx, node, "sudo orama node report --json")
|
|
||||||
|
|
||||||
cs := CollectionStatus{
|
|
||||||
Node: node,
|
|
||||||
Duration: time.Since(start),
|
|
||||||
Retries: result.Retries,
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result.OK() {
|
|
||||||
cs.Error = fmt.Errorf("SSH failed (exit %d): %s", result.ExitCode, truncate(result.Stderr, 200))
|
|
||||||
return cs
|
|
||||||
}
|
|
||||||
|
|
||||||
var rpt report.NodeReport
|
|
||||||
if err := json.Unmarshal([]byte(result.Stdout), &rpt); err != nil {
|
|
||||||
cs.Error = fmt.Errorf("parse report JSON: %w (first 200 bytes: %s)", err, truncate(result.Stdout, 200))
|
|
||||||
return cs
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enrich with node metadata from nodes.conf
|
|
||||||
if rpt.Hostname == "" {
|
|
||||||
rpt.Hostname = node.Host
|
|
||||||
}
|
|
||||||
rpt.PublicIP = node.Host
|
|
||||||
|
|
||||||
cs.Report = &rpt
|
|
||||||
return cs
|
|
||||||
}
|
|
||||||
|
|
||||||
func filterByHost(nodes []inspector.Node, host string) []inspector.Node {
|
|
||||||
var filtered []inspector.Node
|
|
||||||
for _, n := range nodes {
|
|
||||||
if n.Host == host {
|
|
||||||
filtered = append(filtered, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncate(s string, maxLen int) string {
|
|
||||||
if len(s) <= maxLen {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:maxLen] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadNodes resolves the node list and SSH keys based on the environment.
|
|
||||||
// For "sandbox", nodes are loaded from the active sandbox state file with
|
|
||||||
// the sandbox SSH key already set. For other environments, nodes come from
|
|
||||||
// nodes.conf and use wallet-derived SSH keys.
|
|
||||||
func loadNodes(cfg CollectorConfig) ([]inspector.Node, func(), error) {
|
|
||||||
noop := func() {}
|
|
||||||
|
|
||||||
if cfg.Env == "sandbox" {
|
|
||||||
return loadSandboxNodes(cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
nodes, err := inspector.LoadNodes(cfg.ConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, noop, fmt.Errorf("load nodes: %w", err)
|
|
||||||
}
|
|
||||||
nodes = inspector.FilterByEnv(nodes, cfg.Env)
|
|
||||||
if cfg.NodeFilter != "" {
|
|
||||||
nodes = filterByHost(nodes, cfg.NodeFilter)
|
|
||||||
}
|
|
||||||
if len(nodes) == 0 {
|
|
||||||
return nil, noop, fmt.Errorf("no nodes found for env %q", cfg.Env)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup, err := remotessh.PrepareNodeKeys(nodes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, noop, fmt.Errorf("prepare SSH keys: %w", err)
|
|
||||||
}
|
|
||||||
return nodes, cleanup, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadSandboxNodes loads nodes from the active sandbox state file.
|
|
||||||
func loadSandboxNodes(cfg CollectorConfig) ([]inspector.Node, func(), error) {
|
|
||||||
noop := func() {}
|
|
||||||
|
|
||||||
sbxCfg, err := sandbox.LoadConfig()
|
|
||||||
if err != nil {
|
|
||||||
return nil, noop, fmt.Errorf("load sandbox config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
state, err := sandbox.FindActiveSandbox()
|
|
||||||
if err != nil {
|
|
||||||
return nil, noop, fmt.Errorf("find active sandbox: %w", err)
|
|
||||||
}
|
|
||||||
if state == nil {
|
|
||||||
return nil, noop, fmt.Errorf("no active sandbox found")
|
|
||||||
}
|
|
||||||
|
|
||||||
nodes := state.ToNodes(sbxCfg.SSHKey.VaultTarget)
|
|
||||||
if cfg.NodeFilter != "" {
|
|
||||||
nodes = filterByHost(nodes, cfg.NodeFilter)
|
|
||||||
}
|
|
||||||
if len(nodes) == 0 {
|
|
||||||
return nil, noop, fmt.Errorf("no nodes found for sandbox %q", state.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup, err := remotessh.PrepareNodeKeys(nodes)
|
|
||||||
if err != nil {
|
|
||||||
return nil, noop, fmt.Errorf("prepare SSH keys: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodes, cleanup, nil
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
package display
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AlertsTable prints alerts sorted by severity to w.
|
|
||||||
func AlertsTable(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
critCount, warnCount := countAlerts(snap.Alerts)
|
|
||||||
|
|
||||||
fmt.Fprintf(w, "%s\n", styleBold.Render(
|
|
||||||
fmt.Sprintf("Alerts \u2014 %s (%d critical, %d warning)",
|
|
||||||
snap.Environment, critCount, warnCount)))
|
|
||||||
fmt.Fprintln(w, strings.Repeat("\u2550", 44))
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
|
|
||||||
if len(snap.Alerts) == 0 {
|
|
||||||
fmt.Fprintln(w, styleGreen.Render(" No alerts"))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by severity: critical first, then warning, then info
|
|
||||||
sorted := make([]monitor.Alert, len(snap.Alerts))
|
|
||||||
copy(sorted, snap.Alerts)
|
|
||||||
sort.Slice(sorted, func(i, j int) bool {
|
|
||||||
return severityRank(sorted[i].Severity) < severityRank(sorted[j].Severity)
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, a := range sorted {
|
|
||||||
tag := severityTag(a.Severity)
|
|
||||||
node := a.Node
|
|
||||||
if node == "" {
|
|
||||||
node = "cluster"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "%s %-18s %-12s %s\n",
|
|
||||||
tag, node, a.Subsystem, a.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AlertsJSON writes alerts as JSON.
|
|
||||||
func AlertsJSON(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
return writeJSON(w, snap.Alerts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// severityRank returns a sort rank for severity (lower = higher priority).
|
|
||||||
func severityRank(s monitor.AlertSeverity) int {
|
|
||||||
switch s {
|
|
||||||
case monitor.AlertCritical:
|
|
||||||
return 0
|
|
||||||
case monitor.AlertWarning:
|
|
||||||
return 1
|
|
||||||
case monitor.AlertInfo:
|
|
||||||
return 2
|
|
||||||
default:
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,204 +0,0 @@
|
|||||||
package display
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ClusterTable prints a cluster overview table to w.
|
|
||||||
func ClusterTable(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
dur := snap.Duration.Seconds()
|
|
||||||
fmt.Fprintf(w, "%s\n", styleBold.Render(
|
|
||||||
fmt.Sprintf("Cluster Overview \u2014 %s (%d nodes, collected in %.1fs)",
|
|
||||||
snap.Environment, snap.TotalCount(), dur)))
|
|
||||||
fmt.Fprintln(w, strings.Repeat("\u2550", 60))
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
|
|
||||||
// Header
|
|
||||||
fmt.Fprintf(w, "%-18s %-12s %-6s %-6s %-11s %-5s %s\n",
|
|
||||||
styleHeader.Render("NODE"),
|
|
||||||
styleHeader.Render("ROLE"),
|
|
||||||
styleHeader.Render("MEM"),
|
|
||||||
styleHeader.Render("DISK"),
|
|
||||||
styleHeader.Render("RQLITE"),
|
|
||||||
styleHeader.Render("WG"),
|
|
||||||
styleHeader.Render("SERVICES"))
|
|
||||||
fmt.Fprintln(w, separator(70))
|
|
||||||
|
|
||||||
// Healthy nodes
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Error != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r := cs.Report
|
|
||||||
if r == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
host := cs.Node.Host
|
|
||||||
role := cs.Node.Role
|
|
||||||
|
|
||||||
// Memory %
|
|
||||||
memStr := "--"
|
|
||||||
if r.System != nil {
|
|
||||||
memStr = fmt.Sprintf("%d%%", r.System.MemUsePct)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disk %
|
|
||||||
diskStr := "--"
|
|
||||||
if r.System != nil {
|
|
||||||
diskStr = fmt.Sprintf("%d%%", r.System.DiskUsePct)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RQLite state
|
|
||||||
rqliteStr := "--"
|
|
||||||
if r.RQLite != nil && r.RQLite.Responsive {
|
|
||||||
rqliteStr = r.RQLite.RaftState
|
|
||||||
} else if r.RQLite != nil {
|
|
||||||
rqliteStr = styleRed.Render("DOWN")
|
|
||||||
}
|
|
||||||
|
|
||||||
// WireGuard
|
|
||||||
wgStr := statusIcon(r.WireGuard != nil && r.WireGuard.InterfaceUp)
|
|
||||||
|
|
||||||
// Services: active/total
|
|
||||||
svcStr := "--"
|
|
||||||
if r.Services != nil {
|
|
||||||
active := 0
|
|
||||||
total := len(r.Services.Services)
|
|
||||||
for _, svc := range r.Services.Services {
|
|
||||||
if svc.ActiveState == "active" {
|
|
||||||
active++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
svcStr = fmt.Sprintf("%d/%d", active, total)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(w, "%-18s %-12s %-6s %-6s %-11s %-5s %s\n",
|
|
||||||
host, role, memStr, diskStr, rqliteStr, wgStr, svcStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unreachable nodes
|
|
||||||
failed := snap.Failed()
|
|
||||||
if len(failed) > 0 {
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
for _, cs := range failed {
|
|
||||||
fmt.Fprintf(w, "%-18s %-12s %s\n",
|
|
||||||
styleRed.Render(cs.Node.Host),
|
|
||||||
cs.Node.Role,
|
|
||||||
styleRed.Render("UNREACHABLE"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alerts summary
|
|
||||||
critCount, warnCount := countAlerts(snap.Alerts)
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
fmt.Fprintf(w, "Alerts: %s critical, %s warning\n",
|
|
||||||
alertCountStr(critCount, monitor.AlertCritical),
|
|
||||||
alertCountStr(warnCount, monitor.AlertWarning))
|
|
||||||
|
|
||||||
for _, a := range snap.Alerts {
|
|
||||||
if a.Severity == monitor.AlertCritical || a.Severity == monitor.AlertWarning {
|
|
||||||
tag := severityTag(a.Severity)
|
|
||||||
fmt.Fprintf(w, " %s %s: %s\n", tag, a.Node, a.Message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClusterJSON writes the cluster snapshot as JSON.
|
|
||||||
func ClusterJSON(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
type clusterEntry struct {
|
|
||||||
Host string `json:"host"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
MemPct int `json:"mem_pct"`
|
|
||||||
DiskPct int `json:"disk_pct"`
|
|
||||||
RQLite string `json:"rqlite_state"`
|
|
||||||
WGUp bool `json:"wg_up"`
|
|
||||||
Services string `json:"services"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries []clusterEntry
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
e := clusterEntry{
|
|
||||||
Host: cs.Node.Host,
|
|
||||||
Role: cs.Node.Role,
|
|
||||||
}
|
|
||||||
if cs.Error != nil {
|
|
||||||
e.Status = "unreachable"
|
|
||||||
e.Error = cs.Error.Error()
|
|
||||||
entries = append(entries, e)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r := cs.Report
|
|
||||||
if r == nil {
|
|
||||||
e.Status = "unreachable"
|
|
||||||
entries = append(entries, e)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
e.Status = "ok"
|
|
||||||
if r.System != nil {
|
|
||||||
e.MemPct = r.System.MemUsePct
|
|
||||||
e.DiskPct = r.System.DiskUsePct
|
|
||||||
}
|
|
||||||
if r.RQLite != nil && r.RQLite.Responsive {
|
|
||||||
e.RQLite = r.RQLite.RaftState
|
|
||||||
}
|
|
||||||
e.WGUp = r.WireGuard != nil && r.WireGuard.InterfaceUp
|
|
||||||
if r.Services != nil {
|
|
||||||
active := 0
|
|
||||||
total := len(r.Services.Services)
|
|
||||||
for _, svc := range r.Services.Services {
|
|
||||||
if svc.ActiveState == "active" {
|
|
||||||
active++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.Services = fmt.Sprintf("%d/%d", active, total)
|
|
||||||
}
|
|
||||||
entries = append(entries, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return writeJSON(w, entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// countAlerts returns the number of critical and warning alerts.
|
|
||||||
func countAlerts(alerts []monitor.Alert) (crit, warn int) {
|
|
||||||
for _, a := range alerts {
|
|
||||||
switch a.Severity {
|
|
||||||
case monitor.AlertCritical:
|
|
||||||
crit++
|
|
||||||
case monitor.AlertWarning:
|
|
||||||
warn++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// severityTag returns a colored tag like [CRIT], [WARN], [INFO].
|
|
||||||
func severityTag(s monitor.AlertSeverity) string {
|
|
||||||
switch s {
|
|
||||||
case monitor.AlertCritical:
|
|
||||||
return styleRed.Render("[CRIT]")
|
|
||||||
case monitor.AlertWarning:
|
|
||||||
return styleYellow.Render("[WARN]")
|
|
||||||
case monitor.AlertInfo:
|
|
||||||
return styleMuted.Render("[INFO]")
|
|
||||||
default:
|
|
||||||
return styleMuted.Render("[????]")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// alertCountStr renders the count with appropriate color.
|
|
||||||
func alertCountStr(count int, sev monitor.AlertSeverity) string {
|
|
||||||
s := fmt.Sprintf("%d", count)
|
|
||||||
if count > 0 {
|
|
||||||
return severityColor(sev).Render(s)
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
package display
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DNSTable prints DNS status for nameserver nodes to w.
|
|
||||||
func DNSTable(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
fmt.Fprintf(w, "%s\n", styleBold.Render(
|
|
||||||
fmt.Sprintf("DNS Status \u2014 %s", snap.Environment)))
|
|
||||||
fmt.Fprintln(w, strings.Repeat("\u2550", 22))
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
|
|
||||||
// Header
|
|
||||||
fmt.Fprintf(w, "%-18s %-9s %-7s %-5s %-5s %-10s %-10s %s\n",
|
|
||||||
styleHeader.Render("NODE"),
|
|
||||||
styleHeader.Render("COREDNS"),
|
|
||||||
styleHeader.Render("CADDY"),
|
|
||||||
styleHeader.Render("SOA"),
|
|
||||||
styleHeader.Render("NS"),
|
|
||||||
styleHeader.Render("WILDCARD"),
|
|
||||||
styleHeader.Render("BASE TLS"),
|
|
||||||
styleHeader.Render("WILD TLS"))
|
|
||||||
fmt.Fprintln(w, separator(78))
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
// Only show nameserver nodes
|
|
||||||
if !cs.Node.IsNameserver() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
found = true
|
|
||||||
|
|
||||||
if cs.Error != nil || cs.Report == nil {
|
|
||||||
fmt.Fprintf(w, "%-18s %s\n",
|
|
||||||
styleRed.Render(cs.Node.Host),
|
|
||||||
styleRed.Render("UNREACHABLE"))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
r := cs.Report
|
|
||||||
if r.DNS == nil {
|
|
||||||
fmt.Fprintf(w, "%-18s %s\n",
|
|
||||||
cs.Node.Host,
|
|
||||||
styleMuted.Render("no DNS data"))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
dns := r.DNS
|
|
||||||
fmt.Fprintf(w, "%-18s %-9s %-7s %-5s %-5s %-10s %-10s %s\n",
|
|
||||||
cs.Node.Host,
|
|
||||||
statusIcon(dns.CoreDNSActive),
|
|
||||||
statusIcon(dns.CaddyActive),
|
|
||||||
statusIcon(dns.SOAResolves),
|
|
||||||
statusIcon(dns.NSResolves),
|
|
||||||
statusIcon(dns.WildcardResolves),
|
|
||||||
tlsDaysStr(dns.BaseTLSDaysLeft),
|
|
||||||
tlsDaysStr(dns.WildTLSDaysLeft))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
fmt.Fprintln(w, styleMuted.Render(" No nameserver nodes found"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DNSJSON writes DNS status as JSON.
|
|
||||||
func DNSJSON(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
type dnsEntry struct {
|
|
||||||
Host string `json:"host"`
|
|
||||||
CoreDNSActive bool `json:"coredns_active"`
|
|
||||||
CaddyActive bool `json:"caddy_active"`
|
|
||||||
SOAResolves bool `json:"soa_resolves"`
|
|
||||||
NSResolves bool `json:"ns_resolves"`
|
|
||||||
WildcardResolves bool `json:"wildcard_resolves"`
|
|
||||||
BaseTLSDaysLeft int `json:"base_tls_days_left"`
|
|
||||||
WildTLSDaysLeft int `json:"wild_tls_days_left"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries []dnsEntry
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if !cs.Node.IsNameserver() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
e := dnsEntry{Host: cs.Node.Host}
|
|
||||||
if cs.Error != nil {
|
|
||||||
e.Error = cs.Error.Error()
|
|
||||||
entries = append(entries, e)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if cs.Report == nil || cs.Report.DNS == nil {
|
|
||||||
entries = append(entries, e)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
dns := cs.Report.DNS
|
|
||||||
e.CoreDNSActive = dns.CoreDNSActive
|
|
||||||
e.CaddyActive = dns.CaddyActive
|
|
||||||
e.SOAResolves = dns.SOAResolves
|
|
||||||
e.NSResolves = dns.NSResolves
|
|
||||||
e.WildcardResolves = dns.WildcardResolves
|
|
||||||
e.BaseTLSDaysLeft = dns.BaseTLSDaysLeft
|
|
||||||
e.WildTLSDaysLeft = dns.WildTLSDaysLeft
|
|
||||||
entries = append(entries, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return writeJSON(w, entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// tlsDaysStr formats TLS days left with appropriate coloring.
|
|
||||||
func tlsDaysStr(days int) string {
|
|
||||||
if days < 0 {
|
|
||||||
return styleMuted.Render("--")
|
|
||||||
}
|
|
||||||
s := fmt.Sprintf("%d days", days)
|
|
||||||
switch {
|
|
||||||
case days < 7:
|
|
||||||
return styleRed.Render(s)
|
|
||||||
case days < 30:
|
|
||||||
return styleYellow.Render(s)
|
|
||||||
default:
|
|
||||||
return styleGreen.Render(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
package display
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MeshTable prints WireGuard mesh status to w.
|
|
||||||
func MeshTable(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
fmt.Fprintf(w, "%s\n", styleBold.Render(
|
|
||||||
fmt.Sprintf("WireGuard Mesh \u2014 %s", snap.Environment)))
|
|
||||||
fmt.Fprintln(w, strings.Repeat("\u2550", 28))
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
|
|
||||||
// Header
|
|
||||||
fmt.Fprintf(w, "%-18s %-12s %-7s %-7s %s\n",
|
|
||||||
styleHeader.Render("NODE"),
|
|
||||||
styleHeader.Render("WG IP"),
|
|
||||||
styleHeader.Render("PORT"),
|
|
||||||
styleHeader.Render("PEERS"),
|
|
||||||
styleHeader.Render("STATUS"))
|
|
||||||
fmt.Fprintln(w, separator(54))
|
|
||||||
|
|
||||||
// Collect mesh info for peer details
|
|
||||||
type meshNode struct {
|
|
||||||
host string
|
|
||||||
wgIP string
|
|
||||||
port int
|
|
||||||
peers int
|
|
||||||
total int
|
|
||||||
healthy bool
|
|
||||||
}
|
|
||||||
var meshNodes []meshNode
|
|
||||||
|
|
||||||
expectedPeers := snap.HealthyCount() - 1
|
|
||||||
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Error != nil || cs.Report == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r := cs.Report
|
|
||||||
if r.WireGuard == nil {
|
|
||||||
fmt.Fprintf(w, "%-18s %s\n", cs.Node.Host, styleMuted.Render("no WireGuard"))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
wg := r.WireGuard
|
|
||||||
peerCount := wg.PeerCount
|
|
||||||
allOK := wg.InterfaceUp
|
|
||||||
if allOK {
|
|
||||||
for _, p := range wg.Peers {
|
|
||||||
if p.LatestHandshake == 0 || p.HandshakeAgeSec > 180 {
|
|
||||||
allOK = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mn := meshNode{
|
|
||||||
host: cs.Node.Host,
|
|
||||||
wgIP: wg.WgIP,
|
|
||||||
port: wg.ListenPort,
|
|
||||||
peers: peerCount,
|
|
||||||
total: expectedPeers,
|
|
||||||
healthy: allOK,
|
|
||||||
}
|
|
||||||
meshNodes = append(meshNodes, mn)
|
|
||||||
|
|
||||||
peerStr := fmt.Sprintf("%d/%d", peerCount, expectedPeers)
|
|
||||||
statusStr := statusIcon(allOK)
|
|
||||||
if !wg.InterfaceUp {
|
|
||||||
statusStr = styleRed.Render("DOWN")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(w, "%-18s %-12s %-7d %-7s %s\n",
|
|
||||||
cs.Node.Host, wg.WgIP, wg.ListenPort, peerStr, statusStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Peer details
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
fmt.Fprintln(w, styleBold.Render("Peer Details:"))
|
|
||||||
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Error != nil || cs.Report == nil || cs.Report.WireGuard == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
wg := cs.Report.WireGuard
|
|
||||||
if !wg.InterfaceUp {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
localIP := wg.WgIP
|
|
||||||
for _, p := range wg.Peers {
|
|
||||||
hsAge := formatDuration(p.HandshakeAgeSec)
|
|
||||||
rx := formatBytes(p.TransferRx)
|
|
||||||
tx := formatBytes(p.TransferTx)
|
|
||||||
|
|
||||||
peerIP := p.AllowedIPs
|
|
||||||
// Strip CIDR if present
|
|
||||||
if idx := strings.Index(peerIP, "/"); idx > 0 {
|
|
||||||
peerIP = peerIP[:idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
hsColor := styleGreen
|
|
||||||
if p.LatestHandshake == 0 {
|
|
||||||
hsAge = "never"
|
|
||||||
hsColor = styleRed
|
|
||||||
} else if p.HandshakeAgeSec > 180 {
|
|
||||||
hsColor = styleYellow
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(w, " %s \u2194 %s: handshake %s, rx: %s, tx: %s\n",
|
|
||||||
localIP, peerIP, hsColor.Render(hsAge), rx, tx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MeshJSON writes the WireGuard mesh as JSON.
|
|
||||||
func MeshJSON(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
type peerEntry struct {
|
|
||||||
AllowedIPs string `json:"allowed_ips"`
|
|
||||||
HandshakeAgeSec int64 `json:"handshake_age_sec"`
|
|
||||||
TransferRxBytes int64 `json:"transfer_rx_bytes"`
|
|
||||||
TransferTxBytes int64 `json:"transfer_tx_bytes"`
|
|
||||||
}
|
|
||||||
type meshEntry struct {
|
|
||||||
Host string `json:"host"`
|
|
||||||
WgIP string `json:"wg_ip"`
|
|
||||||
ListenPort int `json:"listen_port"`
|
|
||||||
PeerCount int `json:"peer_count"`
|
|
||||||
Up bool `json:"up"`
|
|
||||||
Peers []peerEntry `json:"peers,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries []meshEntry
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Error != nil || cs.Report == nil || cs.Report.WireGuard == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
wg := cs.Report.WireGuard
|
|
||||||
e := meshEntry{
|
|
||||||
Host: cs.Node.Host,
|
|
||||||
WgIP: wg.WgIP,
|
|
||||||
ListenPort: wg.ListenPort,
|
|
||||||
PeerCount: wg.PeerCount,
|
|
||||||
Up: wg.InterfaceUp,
|
|
||||||
}
|
|
||||||
for _, p := range wg.Peers {
|
|
||||||
e.Peers = append(e.Peers, peerEntry{
|
|
||||||
AllowedIPs: p.AllowedIPs,
|
|
||||||
HandshakeAgeSec: p.HandshakeAgeSec,
|
|
||||||
TransferRxBytes: p.TransferRx,
|
|
||||||
TransferTxBytes: p.TransferTx,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
entries = append(entries, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return writeJSON(w, entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatDuration formats seconds into a human-readable string.
|
|
||||||
func formatDuration(sec int64) string {
|
|
||||||
if sec < 60 {
|
|
||||||
return fmt.Sprintf("%ds ago", sec)
|
|
||||||
}
|
|
||||||
if sec < 3600 {
|
|
||||||
return fmt.Sprintf("%dm ago", sec/60)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%dh ago", sec/3600)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatBytes formats bytes into a human-readable string.
|
|
||||||
func formatBytes(b int64) string {
|
|
||||||
const (
|
|
||||||
kb = 1024
|
|
||||||
mb = 1024 * kb
|
|
||||||
gb = 1024 * mb
|
|
||||||
)
|
|
||||||
switch {
|
|
||||||
case b >= gb:
|
|
||||||
return fmt.Sprintf("%.1fGB", float64(b)/float64(gb))
|
|
||||||
case b >= mb:
|
|
||||||
return fmt.Sprintf("%.1fMB", float64(b)/float64(mb))
|
|
||||||
case b >= kb:
|
|
||||||
return fmt.Sprintf("%.1fKB", float64(b)/float64(kb))
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%dB", b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
package display
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NamespacesTable prints per-namespace health across nodes to w.
|
|
||||||
func NamespacesTable(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
fmt.Fprintf(w, "%s\n", styleBold.Render(
|
|
||||||
fmt.Sprintf("Namespace Health \u2014 %s", snap.Environment)))
|
|
||||||
fmt.Fprintln(w, strings.Repeat("\u2550", 28))
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
|
|
||||||
// Collect all namespace entries across nodes
|
|
||||||
type nsRow struct {
|
|
||||||
namespace string
|
|
||||||
host string
|
|
||||||
rqlite string
|
|
||||||
olric string
|
|
||||||
gateway string
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows []nsRow
|
|
||||||
nsNames := map[string]bool{}
|
|
||||||
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Error != nil || cs.Report == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, ns := range cs.Report.Namespaces {
|
|
||||||
nsNames[ns.Name] = true
|
|
||||||
|
|
||||||
rqliteStr := statusIcon(ns.RQLiteUp)
|
|
||||||
if ns.RQLiteUp && ns.RQLiteState != "" {
|
|
||||||
rqliteStr = ns.RQLiteState
|
|
||||||
}
|
|
||||||
|
|
||||||
rows = append(rows, nsRow{
|
|
||||||
namespace: ns.Name,
|
|
||||||
host: cs.Node.Host,
|
|
||||||
rqlite: rqliteStr,
|
|
||||||
olric: statusIcon(ns.OlricUp),
|
|
||||||
gateway: statusIcon(ns.GatewayUp),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rows) == 0 {
|
|
||||||
fmt.Fprintln(w, styleMuted.Render(" No namespaces found"))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by namespace name, then host
|
|
||||||
sort.Slice(rows, func(i, j int) bool {
|
|
||||||
if rows[i].namespace != rows[j].namespace {
|
|
||||||
return rows[i].namespace < rows[j].namespace
|
|
||||||
}
|
|
||||||
return rows[i].host < rows[j].host
|
|
||||||
})
|
|
||||||
|
|
||||||
// Header
|
|
||||||
fmt.Fprintf(w, "%-13s %-18s %-11s %-7s %s\n",
|
|
||||||
styleHeader.Render("NAMESPACE"),
|
|
||||||
styleHeader.Render("NODE"),
|
|
||||||
styleHeader.Render("RQLITE"),
|
|
||||||
styleHeader.Render("OLRIC"),
|
|
||||||
styleHeader.Render("GATEWAY"))
|
|
||||||
fmt.Fprintln(w, separator(58))
|
|
||||||
|
|
||||||
for _, r := range rows {
|
|
||||||
fmt.Fprintf(w, "%-13s %-18s %-11s %-7s %s\n",
|
|
||||||
r.namespace, r.host, r.rqlite, r.olric, r.gateway)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NamespacesJSON writes namespace health as JSON.
|
|
||||||
func NamespacesJSON(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
type nsEntry struct {
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
Host string `json:"host"`
|
|
||||||
RQLiteUp bool `json:"rqlite_up"`
|
|
||||||
RQLiteState string `json:"rqlite_state,omitempty"`
|
|
||||||
OlricUp bool `json:"olric_up"`
|
|
||||||
GatewayUp bool `json:"gateway_up"`
|
|
||||||
GatewayStatus int `json:"gateway_status,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries []nsEntry
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Error != nil || cs.Report == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, ns := range cs.Report.Namespaces {
|
|
||||||
entries = append(entries, nsEntry{
|
|
||||||
Namespace: ns.Name,
|
|
||||||
Host: cs.Node.Host,
|
|
||||||
RQLiteUp: ns.RQLiteUp,
|
|
||||||
RQLiteState: ns.RQLiteState,
|
|
||||||
OlricUp: ns.OlricUp,
|
|
||||||
GatewayUp: ns.GatewayUp,
|
|
||||||
GatewayStatus: ns.GatewayStatus,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return writeJSON(w, entries)
|
|
||||||
}
|
|
||||||
@ -1,167 +0,0 @@
|
|||||||
package display
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NodeTable prints detailed per-node information to w.
|
|
||||||
func NodeTable(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
for i, cs := range snap.Nodes {
|
|
||||||
if i > 0 {
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
}
|
|
||||||
|
|
||||||
host := cs.Node.Host
|
|
||||||
role := cs.Node.Role
|
|
||||||
|
|
||||||
if cs.Error != nil {
|
|
||||||
fmt.Fprintf(w, "%s (%s)\n", styleRed.Render("Node: "+host), role)
|
|
||||||
fmt.Fprintf(w, " %s\n", styleRed.Render(fmt.Sprintf("UNREACHABLE: %v", cs.Error)))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
r := cs.Report
|
|
||||||
if r == nil {
|
|
||||||
fmt.Fprintf(w, "%s (%s)\n", styleRed.Render("Node: "+host), role)
|
|
||||||
fmt.Fprintf(w, " %s\n", styleRed.Render("No report available"))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(w, "%s\n", styleBold.Render(fmt.Sprintf("Node: %s (%s)", host, role)))
|
|
||||||
|
|
||||||
// System
|
|
||||||
if r.System != nil {
|
|
||||||
sys := r.System
|
|
||||||
fmt.Fprintf(w, " System: CPU %d | Load %.2f | Mem %d%% (%d/%d MB) | Disk %d%%\n",
|
|
||||||
sys.CPUCount, sys.LoadAvg1, sys.MemUsePct, sys.MemUsedMB, sys.MemTotalMB, sys.DiskUsePct)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintln(w, " System: "+styleMuted.Render("no data"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// RQLite
|
|
||||||
if r.RQLite != nil {
|
|
||||||
rq := r.RQLite
|
|
||||||
readyStr := styleRed.Render("Not Ready")
|
|
||||||
if rq.Ready {
|
|
||||||
readyStr = styleGreen.Render("Ready")
|
|
||||||
}
|
|
||||||
if rq.Responsive {
|
|
||||||
fmt.Fprintf(w, " RQLite: %s | Term %d | Applied %d | Peers %d | %s\n",
|
|
||||||
rq.RaftState, rq.Term, rq.Applied, rq.NumPeers, readyStr)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(w, " RQLite: %s\n", styleRed.Render("NOT RESPONDING"))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fmt.Fprintln(w, " RQLite: "+styleMuted.Render("not configured"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// WireGuard
|
|
||||||
if r.WireGuard != nil {
|
|
||||||
wg := r.WireGuard
|
|
||||||
if wg.InterfaceUp {
|
|
||||||
// Check handshakes
|
|
||||||
hsOK := true
|
|
||||||
for _, p := range wg.Peers {
|
|
||||||
if p.LatestHandshake == 0 || p.HandshakeAgeSec > 180 {
|
|
||||||
hsOK = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hsStr := statusIcon(hsOK)
|
|
||||||
fmt.Fprintf(w, " WireGuard: UP | %s | %d peers | handshakes %s\n",
|
|
||||||
wg.WgIP, wg.PeerCount, hsStr)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(w, " WireGuard: %s\n", styleRed.Render("DOWN"))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fmt.Fprintln(w, " WireGuard: "+styleMuted.Render("not configured"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Olric
|
|
||||||
if r.Olric != nil {
|
|
||||||
ol := r.Olric
|
|
||||||
stateStr := styleRed.Render("inactive")
|
|
||||||
if ol.ServiceActive {
|
|
||||||
stateStr = styleGreen.Render("active")
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, " Olric: %s | %d members\n", stateStr, ol.MemberCount)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintln(w, " Olric: "+styleMuted.Render("not configured"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPFS
|
|
||||||
if r.IPFS != nil {
|
|
||||||
ipfs := r.IPFS
|
|
||||||
daemonStr := styleRed.Render("inactive")
|
|
||||||
if ipfs.DaemonActive {
|
|
||||||
daemonStr = styleGreen.Render("active")
|
|
||||||
}
|
|
||||||
clusterStr := styleRed.Render("DOWN")
|
|
||||||
if ipfs.ClusterActive {
|
|
||||||
clusterStr = styleGreen.Render("OK")
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, " IPFS: %s | %d swarm peers | cluster %s\n",
|
|
||||||
daemonStr, ipfs.SwarmPeerCount, clusterStr)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintln(w, " IPFS: "+styleMuted.Render("not configured"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anyone
|
|
||||||
if r.Anyone != nil {
|
|
||||||
an := r.Anyone
|
|
||||||
mode := an.Mode
|
|
||||||
if mode == "" {
|
|
||||||
if an.RelayActive {
|
|
||||||
mode = "relay"
|
|
||||||
} else if an.ClientActive {
|
|
||||||
mode = "client"
|
|
||||||
} else {
|
|
||||||
mode = "inactive"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bootStr := styleRed.Render("not bootstrapped")
|
|
||||||
if an.Bootstrapped {
|
|
||||||
bootStr = styleGreen.Render("bootstrapped")
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, " Anyone: %s | %s\n", mode, bootStr)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintln(w, " Anyone: "+styleMuted.Render("not configured"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NodeJSON writes the node details as JSON.
|
|
||||||
func NodeJSON(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
type nodeDetail struct {
|
|
||||||
Host string `json:"host"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
Report interface{} `json:"report,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries []nodeDetail
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
e := nodeDetail{
|
|
||||||
Host: cs.Node.Host,
|
|
||||||
Role: cs.Node.Role,
|
|
||||||
}
|
|
||||||
if cs.Error != nil {
|
|
||||||
e.Status = "unreachable"
|
|
||||||
e.Error = cs.Error.Error()
|
|
||||||
} else if cs.Report != nil {
|
|
||||||
e.Status = "ok"
|
|
||||||
e.Report = cs.Report
|
|
||||||
} else {
|
|
||||||
e.Status = "unknown"
|
|
||||||
}
|
|
||||||
entries = append(entries, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return writeJSON(w, entries)
|
|
||||||
}
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
package display
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/production/report"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fullReport struct {
|
|
||||||
Meta struct {
|
|
||||||
Environment string `json:"environment"`
|
|
||||||
CollectedAt time.Time `json:"collected_at"`
|
|
||||||
DurationSec float64 `json:"duration_seconds"`
|
|
||||||
NodeCount int `json:"node_count"`
|
|
||||||
HealthyCount int `json:"healthy_count"`
|
|
||||||
FailedCount int `json:"failed_count"`
|
|
||||||
} `json:"meta"`
|
|
||||||
Summary struct {
|
|
||||||
RQLiteLeader string `json:"rqlite_leader"`
|
|
||||||
RQLiteQuorum string `json:"rqlite_quorum"`
|
|
||||||
WGMeshStatus string `json:"wg_mesh_status"`
|
|
||||||
ServiceHealth string `json:"service_health"`
|
|
||||||
CriticalAlerts int `json:"critical_alerts"`
|
|
||||||
WarningAlerts int `json:"warning_alerts"`
|
|
||||||
} `json:"summary"`
|
|
||||||
Alerts []monitor.Alert `json:"alerts"`
|
|
||||||
Nodes []nodeEntry `json:"nodes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type nodeEntry struct {
|
|
||||||
Host string `json:"host"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
Status string `json:"status"` // "ok", "unreachable", "degraded"
|
|
||||||
Report *report.NodeReport `json:"report,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FullReport outputs the LLM-optimized JSON report to w.
|
|
||||||
func FullReport(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
fr := fullReport{}
|
|
||||||
|
|
||||||
// Meta
|
|
||||||
fr.Meta.Environment = snap.Environment
|
|
||||||
fr.Meta.CollectedAt = snap.CollectedAt
|
|
||||||
fr.Meta.DurationSec = snap.Duration.Seconds()
|
|
||||||
fr.Meta.NodeCount = snap.TotalCount()
|
|
||||||
fr.Meta.HealthyCount = snap.HealthyCount()
|
|
||||||
fr.Meta.FailedCount = len(snap.Failed())
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
fr.Summary.RQLiteLeader = findRQLiteLeader(snap)
|
|
||||||
fr.Summary.RQLiteQuorum = computeQuorumStatus(snap)
|
|
||||||
fr.Summary.WGMeshStatus = computeWGMeshStatus(snap)
|
|
||||||
fr.Summary.ServiceHealth = computeServiceHealth(snap)
|
|
||||||
|
|
||||||
crit, warn := countAlerts(snap.Alerts)
|
|
||||||
fr.Summary.CriticalAlerts = crit
|
|
||||||
fr.Summary.WarningAlerts = warn
|
|
||||||
|
|
||||||
// Alerts
|
|
||||||
fr.Alerts = snap.Alerts
|
|
||||||
|
|
||||||
// Build set of hosts with critical alerts for "degraded" detection
|
|
||||||
criticalHosts := map[string]bool{}
|
|
||||||
for _, a := range snap.Alerts {
|
|
||||||
if a.Severity == monitor.AlertCritical && a.Node != "" && a.Node != "cluster" {
|
|
||||||
criticalHosts[a.Node] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nodes
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
ne := nodeEntry{
|
|
||||||
Host: cs.Node.Host,
|
|
||||||
Role: cs.Node.Role,
|
|
||||||
}
|
|
||||||
if cs.Error != nil {
|
|
||||||
ne.Status = "unreachable"
|
|
||||||
ne.Error = cs.Error.Error()
|
|
||||||
} else if cs.Report != nil {
|
|
||||||
if criticalHosts[cs.Node.Host] {
|
|
||||||
ne.Status = "degraded"
|
|
||||||
} else {
|
|
||||||
ne.Status = "ok"
|
|
||||||
}
|
|
||||||
ne.Report = cs.Report
|
|
||||||
} else {
|
|
||||||
ne.Status = "unreachable"
|
|
||||||
}
|
|
||||||
fr.Nodes = append(fr.Nodes, ne)
|
|
||||||
}
|
|
||||||
|
|
||||||
return writeJSON(w, fr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// findRQLiteLeader returns the host of the RQLite leader, or "none".
|
|
||||||
func findRQLiteLeader(snap *monitor.ClusterSnapshot) string {
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Report != nil && cs.Report.RQLite != nil && cs.Report.RQLite.RaftState == "Leader" {
|
|
||||||
return cs.Node.Host
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "none"
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeQuorumStatus returns "ok", "degraded", or "lost".
|
|
||||||
func computeQuorumStatus(snap *monitor.ClusterSnapshot) string {
|
|
||||||
total := 0
|
|
||||||
responsive := 0
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Report != nil && cs.Report.RQLite != nil {
|
|
||||||
total++
|
|
||||||
if cs.Report.RQLite.Responsive {
|
|
||||||
responsive++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if total == 0 {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
quorum := (total / 2) + 1
|
|
||||||
if responsive >= quorum {
|
|
||||||
return "ok"
|
|
||||||
}
|
|
||||||
if responsive > 0 {
|
|
||||||
return "degraded"
|
|
||||||
}
|
|
||||||
return "lost"
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeWGMeshStatus returns "ok", "degraded", or "down".
|
|
||||||
func computeWGMeshStatus(snap *monitor.ClusterSnapshot) string {
|
|
||||||
totalWG := 0
|
|
||||||
upCount := 0
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Report != nil && cs.Report.WireGuard != nil {
|
|
||||||
totalWG++
|
|
||||||
if cs.Report.WireGuard.InterfaceUp {
|
|
||||||
upCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if totalWG == 0 {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
if upCount == totalWG {
|
|
||||||
return "ok"
|
|
||||||
}
|
|
||||||
if upCount > 0 {
|
|
||||||
return "degraded"
|
|
||||||
}
|
|
||||||
return "down"
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeServiceHealth returns "ok", "degraded", or "critical".
|
|
||||||
func computeServiceHealth(snap *monitor.ClusterSnapshot) string {
|
|
||||||
totalSvc := 0
|
|
||||||
failedSvc := 0
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Report == nil || cs.Report.Services == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, svc := range cs.Report.Services.Services {
|
|
||||||
totalSvc++
|
|
||||||
if svc.ActiveState == "failed" {
|
|
||||||
failedSvc++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if totalSvc == 0 {
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
if failedSvc == 0 {
|
|
||||||
return "ok"
|
|
||||||
}
|
|
||||||
if failedSvc < totalSvc/2 {
|
|
||||||
return "degraded"
|
|
||||||
}
|
|
||||||
return "critical"
|
|
||||||
}
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
package display
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ServiceTable prints a cross-node service status matrix to w.
|
|
||||||
func ServiceTable(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
fmt.Fprintf(w, "%s\n", styleBold.Render(
|
|
||||||
fmt.Sprintf("Service Status Matrix \u2014 %s", snap.Environment)))
|
|
||||||
fmt.Fprintln(w, strings.Repeat("\u2550", 36))
|
|
||||||
fmt.Fprintln(w)
|
|
||||||
|
|
||||||
// Collect all service names and build per-host maps
|
|
||||||
type hostServices struct {
|
|
||||||
host string
|
|
||||||
shortIP string
|
|
||||||
services map[string]string // name -> active_state
|
|
||||||
}
|
|
||||||
|
|
||||||
var hosts []hostServices
|
|
||||||
serviceSet := map[string]bool{}
|
|
||||||
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Error != nil || cs.Report == nil || cs.Report.Services == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hs := hostServices{
|
|
||||||
host: cs.Node.Host,
|
|
||||||
shortIP: shortIP(cs.Node.Host),
|
|
||||||
services: make(map[string]string),
|
|
||||||
}
|
|
||||||
for _, svc := range cs.Report.Services.Services {
|
|
||||||
hs.services[svc.Name] = svc.ActiveState
|
|
||||||
serviceSet[svc.Name] = true
|
|
||||||
}
|
|
||||||
hosts = append(hosts, hs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort service names
|
|
||||||
var svcNames []string
|
|
||||||
for name := range serviceSet {
|
|
||||||
svcNames = append(svcNames, name)
|
|
||||||
}
|
|
||||||
sort.Strings(svcNames)
|
|
||||||
|
|
||||||
if len(hosts) == 0 || len(svcNames) == 0 {
|
|
||||||
fmt.Fprintln(w, styleMuted.Render(" No service data available"))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Header: SERVICE + each host short IP
|
|
||||||
hdr := fmt.Sprintf("%-22s", styleHeader.Render("SERVICE"))
|
|
||||||
for _, h := range hosts {
|
|
||||||
hdr += fmt.Sprintf("%-12s", styleHeader.Render(h.shortIP))
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w, hdr)
|
|
||||||
fmt.Fprintln(w, separator(22+12*len(hosts)))
|
|
||||||
|
|
||||||
// Rows
|
|
||||||
for _, name := range svcNames {
|
|
||||||
row := fmt.Sprintf("%-22s", name)
|
|
||||||
for _, h := range hosts {
|
|
||||||
state, ok := h.services[name]
|
|
||||||
if !ok {
|
|
||||||
row += fmt.Sprintf("%-12s", styleMuted.Render("--"))
|
|
||||||
} else {
|
|
||||||
row += fmt.Sprintf("%-12s", colorServiceState(state))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w, row)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServiceJSON writes the service matrix as JSON.
|
|
||||||
func ServiceJSON(snap *monitor.ClusterSnapshot, w io.Writer) error {
|
|
||||||
type svcEntry struct {
|
|
||||||
Host string `json:"host"`
|
|
||||||
Services map[string]string `json:"services"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var entries []svcEntry
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Error != nil || cs.Report == nil || cs.Report.Services == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
e := svcEntry{
|
|
||||||
Host: cs.Node.Host,
|
|
||||||
Services: make(map[string]string),
|
|
||||||
}
|
|
||||||
for _, svc := range cs.Report.Services.Services {
|
|
||||||
e.Services[svc.Name] = svc.ActiveState
|
|
||||||
}
|
|
||||||
entries = append(entries, e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return writeJSON(w, entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// shortIP truncates an IP to the first 3 octets for compact display.
|
|
||||||
func shortIP(ip string) string {
|
|
||||||
parts := strings.Split(ip, ".")
|
|
||||||
if len(parts) == 4 {
|
|
||||||
return parts[0] + "." + parts[1] + "." + parts[2]
|
|
||||||
}
|
|
||||||
if len(ip) > 12 {
|
|
||||||
return ip[:12]
|
|
||||||
}
|
|
||||||
return ip
|
|
||||||
}
|
|
||||||
|
|
||||||
// colorServiceState renders a service state with appropriate color.
|
|
||||||
func colorServiceState(state string) string {
|
|
||||||
switch state {
|
|
||||||
case "active":
|
|
||||||
return styleGreen.Render("ACTIVE")
|
|
||||||
case "failed":
|
|
||||||
return styleRed.Render("FAILED")
|
|
||||||
case "inactive":
|
|
||||||
return styleMuted.Render("inactive")
|
|
||||||
default:
|
|
||||||
return styleYellow.Render(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
package display
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
styleGreen = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff00"))
|
|
||||||
styleRed = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff0000"))
|
|
||||||
styleYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffff00"))
|
|
||||||
styleMuted = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
|
|
||||||
styleBold = lipgloss.NewStyle().Bold(true)
|
|
||||||
styleHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#ffffff"))
|
|
||||||
)
|
|
||||||
|
|
||||||
// statusIcon returns a green "OK" or red "!!" indicator.
|
|
||||||
func statusIcon(ok bool) string {
|
|
||||||
if ok {
|
|
||||||
return styleGreen.Render("OK")
|
|
||||||
}
|
|
||||||
return styleRed.Render("!!")
|
|
||||||
}
|
|
||||||
|
|
||||||
// severityColor returns the lipgloss style for a given alert severity.
|
|
||||||
func severityColor(s monitor.AlertSeverity) lipgloss.Style {
|
|
||||||
switch s {
|
|
||||||
case monitor.AlertCritical:
|
|
||||||
return styleRed
|
|
||||||
case monitor.AlertWarning:
|
|
||||||
return styleYellow
|
|
||||||
case monitor.AlertInfo:
|
|
||||||
return styleMuted
|
|
||||||
default:
|
|
||||||
return styleMuted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// separator returns a dashed line of the given width.
|
|
||||||
func separator(width int) string {
|
|
||||||
return strings.Repeat("\u2500", width)
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeJSON encodes v as indented JSON to w.
|
|
||||||
func writeJSON(w io.Writer, v interface{}) error {
|
|
||||||
enc := json.NewEncoder(w)
|
|
||||||
enc.SetIndent("", " ")
|
|
||||||
return enc.Encode(v)
|
|
||||||
}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
package monitor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/production/report"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CollectionStatus tracks the SSH collection result for a single node.
|
|
||||||
type CollectionStatus struct {
|
|
||||||
Node inspector.Node
|
|
||||||
Report *report.NodeReport
|
|
||||||
Error error
|
|
||||||
Duration time.Duration
|
|
||||||
Retries int
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClusterSnapshot is the aggregated state of the entire cluster at a point in time.
|
|
||||||
type ClusterSnapshot struct {
|
|
||||||
Environment string
|
|
||||||
CollectedAt time.Time
|
|
||||||
Duration time.Duration
|
|
||||||
Nodes []CollectionStatus
|
|
||||||
Alerts []Alert
|
|
||||||
}
|
|
||||||
|
|
||||||
// Healthy returns only nodes that reported successfully.
|
|
||||||
func (cs *ClusterSnapshot) Healthy() []*report.NodeReport {
|
|
||||||
var out []*report.NodeReport
|
|
||||||
for _, n := range cs.Nodes {
|
|
||||||
if n.Report != nil {
|
|
||||||
out = append(out, n.Report)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Failed returns nodes where SSH or parsing failed.
|
|
||||||
func (cs *ClusterSnapshot) Failed() []CollectionStatus {
|
|
||||||
var out []CollectionStatus
|
|
||||||
for _, n := range cs.Nodes {
|
|
||||||
if n.Error != nil {
|
|
||||||
out = append(out, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ByHost returns a map of host -> NodeReport for quick lookup.
|
|
||||||
func (cs *ClusterSnapshot) ByHost() map[string]*report.NodeReport {
|
|
||||||
m := make(map[string]*report.NodeReport, len(cs.Nodes))
|
|
||||||
for _, n := range cs.Nodes {
|
|
||||||
if n.Report != nil {
|
|
||||||
m[n.Node.Host] = n.Report
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// HealthyCount returns the number of nodes that reported successfully.
|
|
||||||
func (cs *ClusterSnapshot) HealthyCount() int {
|
|
||||||
count := 0
|
|
||||||
for _, n := range cs.Nodes {
|
|
||||||
if n.Report != nil {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// TotalCount returns the total number of nodes attempted.
|
|
||||||
func (cs *ClusterSnapshot) TotalCount() int {
|
|
||||||
return len(cs.Nodes)
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// renderAlertsTab renders all alerts sorted by severity.
|
|
||||||
func renderAlertsTab(snap *monitor.ClusterSnapshot, width int) string {
|
|
||||||
if snap == nil {
|
|
||||||
return styleMuted.Render("Collecting cluster data...")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(snap.Alerts) == 0 {
|
|
||||||
return styleHealthy.Render(" No alerts. All systems nominal.")
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
critCount, warnCount, infoCount := countAlertsBySeverity(snap.Alerts)
|
|
||||||
b.WriteString(styleBold.Render("Alerts"))
|
|
||||||
b.WriteString(fmt.Sprintf(" %s %s %s\n",
|
|
||||||
styleCritical.Render(fmt.Sprintf("%d critical", critCount)),
|
|
||||||
styleWarning.Render(fmt.Sprintf("%d warning", warnCount)),
|
|
||||||
styleMuted.Render(fmt.Sprintf("%d info", infoCount)),
|
|
||||||
))
|
|
||||||
b.WriteString(separator(width))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
// Sort: critical first, then warning, then info
|
|
||||||
sorted := make([]monitor.Alert, len(snap.Alerts))
|
|
||||||
copy(sorted, snap.Alerts)
|
|
||||||
sort.Slice(sorted, func(i, j int) bool {
|
|
||||||
return severityRank(sorted[i].Severity) < severityRank(sorted[j].Severity)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Group by severity
|
|
||||||
currentSev := monitor.AlertSeverity("")
|
|
||||||
for _, a := range sorted {
|
|
||||||
if a.Severity != currentSev {
|
|
||||||
currentSev = a.Severity
|
|
||||||
label := strings.ToUpper(string(a.Severity))
|
|
||||||
b.WriteString(severityStyle(string(a.Severity)).Render(fmt.Sprintf(" ── %s ", label)))
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
sevTag := formatSeverityTag(a.Severity)
|
|
||||||
b.WriteString(fmt.Sprintf(" %s %-12s %-18s %s\n",
|
|
||||||
sevTag,
|
|
||||||
styleMuted.Render("["+a.Subsystem+"]"),
|
|
||||||
a.Node,
|
|
||||||
a.Message,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// severityRank returns a sort rank (lower = more severe).
|
|
||||||
func severityRank(s monitor.AlertSeverity) int {
|
|
||||||
switch s {
|
|
||||||
case monitor.AlertCritical:
|
|
||||||
return 0
|
|
||||||
case monitor.AlertWarning:
|
|
||||||
return 1
|
|
||||||
case monitor.AlertInfo:
|
|
||||||
return 2
|
|
||||||
default:
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatSeverityTag returns a styled severity label.
|
|
||||||
func formatSeverityTag(s monitor.AlertSeverity) string {
|
|
||||||
switch s {
|
|
||||||
case monitor.AlertCritical:
|
|
||||||
return styleCritical.Render("CRIT")
|
|
||||||
case monitor.AlertWarning:
|
|
||||||
return styleWarning.Render("WARN")
|
|
||||||
case monitor.AlertInfo:
|
|
||||||
return styleMuted.Render("INFO")
|
|
||||||
default:
|
|
||||||
return styleMuted.Render("????")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// renderDNSTab renders DNS status for nameserver nodes.
|
|
||||||
func renderDNSTab(snap *monitor.ClusterSnapshot, width int) string {
|
|
||||||
if snap == nil {
|
|
||||||
return styleMuted.Render("Collecting cluster data...")
|
|
||||||
}
|
|
||||||
|
|
||||||
if snap.HealthyCount() == 0 {
|
|
||||||
return styleMuted.Render("No healthy nodes to display.")
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
b.WriteString(styleBold.Render("DNS / Nameserver Status"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(separator(width))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
hasDNS := false
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Report == nil || cs.Report.DNS == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hasDNS = true
|
|
||||||
r := cs.Report
|
|
||||||
dns := r.DNS
|
|
||||||
host := nodeHost(r)
|
|
||||||
role := cs.Node.Role
|
|
||||||
|
|
||||||
b.WriteString(styleBold.Render(fmt.Sprintf(" %s", host)))
|
|
||||||
if role != "" {
|
|
||||||
b.WriteString(fmt.Sprintf(" (%s)", role))
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// Service status
|
|
||||||
b.WriteString(fmt.Sprintf(" CoreDNS: %s", statusStr(dns.CoreDNSActive)))
|
|
||||||
if dns.CoreDNSMemMB > 0 {
|
|
||||||
b.WriteString(fmt.Sprintf(" mem=%dMB", dns.CoreDNSMemMB))
|
|
||||||
}
|
|
||||||
if dns.CoreDNSRestarts > 0 {
|
|
||||||
b.WriteString(fmt.Sprintf(" restarts=%s", styleWarning.Render(fmt.Sprintf("%d", dns.CoreDNSRestarts))))
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
b.WriteString(fmt.Sprintf(" Caddy: %s\n", statusStr(dns.CaddyActive)))
|
|
||||||
|
|
||||||
// Port bindings
|
|
||||||
b.WriteString(fmt.Sprintf(" Ports: 53=%s 80=%s 443=%s\n",
|
|
||||||
statusStr(dns.Port53Bound),
|
|
||||||
statusStr(dns.Port80Bound),
|
|
||||||
statusStr(dns.Port443Bound),
|
|
||||||
))
|
|
||||||
|
|
||||||
// DNS resolution checks
|
|
||||||
b.WriteString(fmt.Sprintf(" SOA: %s\n", statusStr(dns.SOAResolves)))
|
|
||||||
b.WriteString(fmt.Sprintf(" NS: %s", statusStr(dns.NSResolves)))
|
|
||||||
if dns.NSRecordCount > 0 {
|
|
||||||
b.WriteString(fmt.Sprintf(" (%d records)", dns.NSRecordCount))
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(fmt.Sprintf(" Base A: %s\n", statusStr(dns.BaseAResolves)))
|
|
||||||
b.WriteString(fmt.Sprintf(" Wildcard: %s\n", statusStr(dns.WildcardResolves)))
|
|
||||||
b.WriteString(fmt.Sprintf(" Corefile: %s\n", statusStr(dns.CorefileExists)))
|
|
||||||
|
|
||||||
// TLS certificates
|
|
||||||
baseTLS := renderTLSDays(dns.BaseTLSDaysLeft, "base")
|
|
||||||
wildTLS := renderTLSDays(dns.WildTLSDaysLeft, "wildcard")
|
|
||||||
b.WriteString(fmt.Sprintf(" TLS: %s %s\n", baseTLS, wildTLS))
|
|
||||||
|
|
||||||
// Log errors
|
|
||||||
if dns.LogErrors > 0 {
|
|
||||||
b.WriteString(fmt.Sprintf(" Log errors: %s (5m)\n",
|
|
||||||
styleWarning.Render(fmt.Sprintf("%d", dns.LogErrors))))
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasDNS {
|
|
||||||
return styleMuted.Render("No nameserver nodes found (no DNS data reported).")
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderTLSDays formats TLS certificate expiry with color coding.
|
|
||||||
func renderTLSDays(days int, label string) string {
|
|
||||||
if days < 0 {
|
|
||||||
return styleMuted.Render(fmt.Sprintf("%s: n/a", label))
|
|
||||||
}
|
|
||||||
s := fmt.Sprintf("%s: %dd", label, days)
|
|
||||||
switch {
|
|
||||||
case days < 7:
|
|
||||||
return styleCritical.Render(s)
|
|
||||||
case days < 14:
|
|
||||||
return styleWarning.Render(s)
|
|
||||||
default:
|
|
||||||
return styleHealthy.Render(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import "github.com/charmbracelet/bubbles/key"
|
|
||||||
|
|
||||||
type keyMap struct {
|
|
||||||
Quit key.Binding
|
|
||||||
NextTab key.Binding
|
|
||||||
PrevTab key.Binding
|
|
||||||
Refresh key.Binding
|
|
||||||
ScrollUp key.Binding
|
|
||||||
ScrollDown key.Binding
|
|
||||||
}
|
|
||||||
|
|
||||||
var keys = keyMap{
|
|
||||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
|
||||||
NextTab: key.NewBinding(key.WithKeys("tab", "l"), key.WithHelp("tab", "next tab")),
|
|
||||||
PrevTab: key.NewBinding(key.WithKeys("shift+tab", "h"), key.WithHelp("shift+tab", "prev tab")),
|
|
||||||
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
|
|
||||||
ScrollUp: key.NewBinding(key.WithKeys("up", "k")),
|
|
||||||
ScrollDown: key.NewBinding(key.WithKeys("down", "j")),
|
|
||||||
}
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
tabOverview = iota
|
|
||||||
tabNodes
|
|
||||||
tabServices
|
|
||||||
tabMesh
|
|
||||||
tabDNS
|
|
||||||
tabNamespaces
|
|
||||||
tabAlerts
|
|
||||||
tabCount
|
|
||||||
)
|
|
||||||
|
|
||||||
var tabNames = []string{"Overview", "Nodes", "Services", "WG Mesh", "DNS", "Namespaces", "Alerts"}
|
|
||||||
|
|
||||||
// snapshotMsg carries the result of a background collection.
|
|
||||||
type snapshotMsg struct {
|
|
||||||
snap *monitor.ClusterSnapshot
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// tickMsg fires on each refresh interval.
|
|
||||||
type tickMsg time.Time
|
|
||||||
|
|
||||||
// model is the root Bubbletea model for the Orama monitor TUI.
|
|
||||||
type model struct {
|
|
||||||
cfg monitor.CollectorConfig
|
|
||||||
interval time.Duration
|
|
||||||
activeTab int
|
|
||||||
viewport viewport.Model
|
|
||||||
width int
|
|
||||||
height int
|
|
||||||
snapshot *monitor.ClusterSnapshot
|
|
||||||
loading bool
|
|
||||||
lastError error
|
|
||||||
lastUpdate time.Time
|
|
||||||
quitting bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// newModel creates a fresh model with default viewport dimensions.
|
|
||||||
func newModel(cfg monitor.CollectorConfig, interval time.Duration) model {
|
|
||||||
vp := viewport.New(80, 24)
|
|
||||||
return model{
|
|
||||||
cfg: cfg,
|
|
||||||
interval: interval,
|
|
||||||
viewport: vp,
|
|
||||||
loading: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) Init() tea.Cmd {
|
|
||||||
return tea.Batch(doCollect(m.cfg), tickCmd(m.interval))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
var cmds []tea.Cmd
|
|
||||||
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.KeyMsg:
|
|
||||||
switch {
|
|
||||||
case msg.String() == "q" || msg.String() == "ctrl+c":
|
|
||||||
m.quitting = true
|
|
||||||
return m, tea.Quit
|
|
||||||
|
|
||||||
case msg.String() == "tab" || msg.String() == "l":
|
|
||||||
m.activeTab = (m.activeTab + 1) % tabCount
|
|
||||||
m.updateContent()
|
|
||||||
m.viewport.GotoTop()
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case msg.String() == "shift+tab" || msg.String() == "h":
|
|
||||||
m.activeTab = (m.activeTab - 1 + tabCount) % tabCount
|
|
||||||
m.updateContent()
|
|
||||||
m.viewport.GotoTop()
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case msg.String() == "r":
|
|
||||||
if !m.loading {
|
|
||||||
m.loading = true
|
|
||||||
return m, doCollect(m.cfg)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Delegate scrolling to viewport
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.viewport, cmd = m.viewport.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.width = msg.Width
|
|
||||||
m.height = msg.Height
|
|
||||||
// Reserve 4 lines: header, tab bar, blank separator, footer
|
|
||||||
vpHeight := msg.Height - 4
|
|
||||||
if vpHeight < 1 {
|
|
||||||
vpHeight = 1
|
|
||||||
}
|
|
||||||
m.viewport.Width = msg.Width
|
|
||||||
m.viewport.Height = vpHeight
|
|
||||||
m.updateContent()
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case snapshotMsg:
|
|
||||||
m.loading = false
|
|
||||||
if msg.err != nil {
|
|
||||||
m.lastError = msg.err
|
|
||||||
} else {
|
|
||||||
m.snapshot = msg.snap
|
|
||||||
m.lastError = nil
|
|
||||||
m.lastUpdate = time.Now()
|
|
||||||
}
|
|
||||||
m.updateContent()
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case tickMsg:
|
|
||||||
if !m.loading {
|
|
||||||
m.loading = true
|
|
||||||
cmds = append(cmds, doCollect(m.cfg))
|
|
||||||
}
|
|
||||||
cmds = append(cmds, tickCmd(m.interval))
|
|
||||||
return m, tea.Batch(cmds...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m model) View() string {
|
|
||||||
if m.quitting {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Header
|
|
||||||
var header string
|
|
||||||
if m.snapshot != nil {
|
|
||||||
ago := time.Since(m.lastUpdate).Truncate(time.Second)
|
|
||||||
header = headerStyle.Render(fmt.Sprintf(
|
|
||||||
"Orama Monitor — %s — Last: %s (%s ago)",
|
|
||||||
m.snapshot.Environment,
|
|
||||||
m.lastUpdate.Format("15:04:05"),
|
|
||||||
ago,
|
|
||||||
))
|
|
||||||
} else if m.loading {
|
|
||||||
header = headerStyle.Render("Orama Monitor — collecting...")
|
|
||||||
} else if m.lastError != nil {
|
|
||||||
header = headerStyle.Render(fmt.Sprintf("Orama Monitor — error: %v", m.lastError))
|
|
||||||
} else {
|
|
||||||
header = headerStyle.Render("Orama Monitor")
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.loading && m.snapshot != nil {
|
|
||||||
header += styleMuted.Render(" (refreshing...)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tab bar
|
|
||||||
tabs := renderTabBar(m.activeTab, m.width)
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
footer := footerStyle.Render("tab: switch | j/k: scroll | r: refresh | q: quit")
|
|
||||||
|
|
||||||
return header + "\n" + tabs + "\n" + m.viewport.View() + "\n" + footer
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateContent renders the active tab and sets it on the viewport.
|
|
||||||
func (m *model) updateContent() {
|
|
||||||
w := m.width
|
|
||||||
if w == 0 {
|
|
||||||
w = 80
|
|
||||||
}
|
|
||||||
|
|
||||||
var content string
|
|
||||||
switch m.activeTab {
|
|
||||||
case tabOverview:
|
|
||||||
content = renderOverview(m.snapshot, w)
|
|
||||||
case tabNodes:
|
|
||||||
content = renderNodes(m.snapshot, w)
|
|
||||||
case tabServices:
|
|
||||||
content = renderServicesTab(m.snapshot, w)
|
|
||||||
case tabMesh:
|
|
||||||
content = renderWGMesh(m.snapshot, w)
|
|
||||||
case tabDNS:
|
|
||||||
content = renderDNSTab(m.snapshot, w)
|
|
||||||
case tabNamespaces:
|
|
||||||
content = renderNamespacesTab(m.snapshot, w)
|
|
||||||
case tabAlerts:
|
|
||||||
content = renderAlertsTab(m.snapshot, w)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.viewport.SetContent(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// doCollect returns a tea.Cmd that runs monitor.CollectOnce in a goroutine.
|
|
||||||
func doCollect(cfg monitor.CollectorConfig) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
snap, err := monitor.CollectOnce(ctx, cfg)
|
|
||||||
return snapshotMsg{snap: snap, err: err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// tickCmd returns a tea.Cmd that fires a tickMsg after the given interval.
|
|
||||||
func tickCmd(d time.Duration) tea.Cmd {
|
|
||||||
return tea.Tick(d, func(t time.Time) tea.Msg {
|
|
||||||
return tickMsg(t)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run starts the TUI program with the given collector config.
|
|
||||||
func Run(cfg monitor.CollectorConfig) error {
|
|
||||||
m := newModel(cfg, 30*time.Second)
|
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
|
||||||
_, err := p.Run()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// renderNamespacesTab renders per-namespace health across all nodes.
|
|
||||||
func renderNamespacesTab(snap *monitor.ClusterSnapshot, width int) string {
|
|
||||||
if snap == nil {
|
|
||||||
return styleMuted.Render("Collecting cluster data...")
|
|
||||||
}
|
|
||||||
|
|
||||||
reports := snap.Healthy()
|
|
||||||
if len(reports) == 0 {
|
|
||||||
return styleMuted.Render("No healthy nodes to display.")
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
b.WriteString(styleBold.Render("Namespace Health"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(separator(width))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
// Collect unique namespace names
|
|
||||||
nsSet := make(map[string]bool)
|
|
||||||
for _, r := range reports {
|
|
||||||
for _, ns := range r.Namespaces {
|
|
||||||
nsSet[ns.Name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nsNames := make([]string, 0, len(nsSet))
|
|
||||||
for name := range nsSet {
|
|
||||||
nsNames = append(nsNames, name)
|
|
||||||
}
|
|
||||||
sort.Strings(nsNames)
|
|
||||||
|
|
||||||
if len(nsNames) == 0 {
|
|
||||||
return styleMuted.Render("No namespaces found on any node.")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Header
|
|
||||||
header := fmt.Sprintf(" %-20s", headerStyle.Render("NAMESPACE"))
|
|
||||||
for _, r := range reports {
|
|
||||||
host := nodeHost(r)
|
|
||||||
if len(host) > 15 {
|
|
||||||
host = host[:15]
|
|
||||||
}
|
|
||||||
header += fmt.Sprintf(" %-17s", headerStyle.Render(host))
|
|
||||||
}
|
|
||||||
b.WriteString(header)
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// Build lookup: host -> ns name -> NamespaceReport
|
|
||||||
type nsKey struct {
|
|
||||||
host string
|
|
||||||
name string
|
|
||||||
}
|
|
||||||
nsMap := make(map[nsKey]nsStatus)
|
|
||||||
for _, r := range reports {
|
|
||||||
host := nodeHost(r)
|
|
||||||
for _, ns := range r.Namespaces {
|
|
||||||
nsMap[nsKey{host, ns.Name}] = nsStatus{
|
|
||||||
gateway: ns.GatewayUp,
|
|
||||||
rqlite: ns.RQLiteUp,
|
|
||||||
rqliteState: ns.RQLiteState,
|
|
||||||
rqliteReady: ns.RQLiteReady,
|
|
||||||
olric: ns.OlricUp,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rows
|
|
||||||
for _, nsName := range nsNames {
|
|
||||||
row := fmt.Sprintf(" %-20s", nsName)
|
|
||||||
for _, r := range reports {
|
|
||||||
host := nodeHost(r)
|
|
||||||
ns, ok := nsMap[nsKey{host, nsName}]
|
|
||||||
if !ok {
|
|
||||||
row += fmt.Sprintf(" %-17s", styleMuted.Render("-"))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
row += fmt.Sprintf(" %-17s", renderNsCell(ns))
|
|
||||||
}
|
|
||||||
b.WriteString(row)
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detailed per-namespace view
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(styleBold.Render("Namespace Details"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(separator(width))
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
for _, nsName := range nsNames {
|
|
||||||
b.WriteString(fmt.Sprintf("\n %s\n", styleBold.Render(nsName)))
|
|
||||||
for _, r := range reports {
|
|
||||||
host := nodeHost(r)
|
|
||||||
for _, ns := range r.Namespaces {
|
|
||||||
if ns.Name != nsName {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b.WriteString(fmt.Sprintf(" %-18s gw=%s rqlite=%s",
|
|
||||||
host,
|
|
||||||
statusStr(ns.GatewayUp),
|
|
||||||
statusStr(ns.RQLiteUp),
|
|
||||||
))
|
|
||||||
if ns.RQLiteState != "" {
|
|
||||||
b.WriteString(fmt.Sprintf("(%s)", ns.RQLiteState))
|
|
||||||
}
|
|
||||||
b.WriteString(fmt.Sprintf(" olric=%s", statusStr(ns.OlricUp)))
|
|
||||||
if ns.PortBase > 0 {
|
|
||||||
b.WriteString(fmt.Sprintf(" port=%d", ns.PortBase))
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// nsStatus holds a namespace's health indicators for one node.
|
|
||||||
type nsStatus struct {
|
|
||||||
gateway bool
|
|
||||||
rqlite bool
|
|
||||||
rqliteState string
|
|
||||||
rqliteReady bool
|
|
||||||
olric bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderNsCell renders a compact cell for the namespace matrix.
|
|
||||||
func renderNsCell(ns nsStatus) string {
|
|
||||||
if ns.gateway && ns.rqlite && ns.olric {
|
|
||||||
return styleHealthy.Render("OK")
|
|
||||||
}
|
|
||||||
if !ns.gateway && !ns.rqlite {
|
|
||||||
return styleCritical.Render("DOWN")
|
|
||||||
}
|
|
||||||
// Partial
|
|
||||||
parts := []string{}
|
|
||||||
if !ns.gateway {
|
|
||||||
parts = append(parts, "gw")
|
|
||||||
}
|
|
||||||
if !ns.rqlite {
|
|
||||||
parts = append(parts, "rq")
|
|
||||||
}
|
|
||||||
if !ns.olric {
|
|
||||||
parts = append(parts, "ol")
|
|
||||||
}
|
|
||||||
return styleWarning.Render("!" + strings.Join(parts, ","))
|
|
||||||
}
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// renderNodes renders the Nodes tab with detailed per-node information.
|
|
||||||
func renderNodes(snap *monitor.ClusterSnapshot, width int) string {
|
|
||||||
if snap == nil {
|
|
||||||
return styleMuted.Render("Collecting cluster data...")
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
for i, cs := range snap.Nodes {
|
|
||||||
if i > 0 {
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
host := cs.Node.Host
|
|
||||||
role := cs.Node.Role
|
|
||||||
if role == "" {
|
|
||||||
role = "node"
|
|
||||||
}
|
|
||||||
|
|
||||||
if cs.Error != nil {
|
|
||||||
b.WriteString(styleBold.Render(fmt.Sprintf("Node: %s", host)))
|
|
||||||
b.WriteString(fmt.Sprintf(" (%s)", role))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(separator(width))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(fmt.Sprintf(" Status: %s\n", styleCritical.Render("UNREACHABLE")))
|
|
||||||
b.WriteString(fmt.Sprintf(" Error: %s\n", styleCritical.Render(cs.Error.Error())))
|
|
||||||
b.WriteString(fmt.Sprintf(" Took: %s\n", styleMuted.Render(cs.Duration.Truncate(time.Millisecond).String())))
|
|
||||||
if cs.Retries > 0 {
|
|
||||||
b.WriteString(fmt.Sprintf(" Retries: %d\n", cs.Retries))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
r := cs.Report
|
|
||||||
if r == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString(styleBold.Render(fmt.Sprintf("Node: %s", host)))
|
|
||||||
b.WriteString(fmt.Sprintf(" (%s) ", role))
|
|
||||||
b.WriteString(styleHealthy.Render("ONLINE"))
|
|
||||||
if r.Version != "" {
|
|
||||||
b.WriteString(fmt.Sprintf(" v%s", r.Version))
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(separator(width))
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// System Resources
|
|
||||||
if r.System != nil {
|
|
||||||
sys := r.System
|
|
||||||
b.WriteString(styleBold.Render(" System"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(fmt.Sprintf(" CPU: %d cores, load %.1f / %.1f / %.1f\n",
|
|
||||||
sys.CPUCount, sys.LoadAvg1, sys.LoadAvg5, sys.LoadAvg15))
|
|
||||||
b.WriteString(fmt.Sprintf(" Memory: %s (%d / %d MB, %d MB avail)\n",
|
|
||||||
colorPct(sys.MemUsePct), sys.MemUsedMB, sys.MemTotalMB, sys.MemAvailMB))
|
|
||||||
b.WriteString(fmt.Sprintf(" Disk: %s (%s / %s, %s avail)\n",
|
|
||||||
colorPct(sys.DiskUsePct), sys.DiskUsedGB, sys.DiskTotalGB, sys.DiskAvailGB))
|
|
||||||
if sys.SwapTotalMB > 0 {
|
|
||||||
b.WriteString(fmt.Sprintf(" Swap: %d / %d MB\n", sys.SwapUsedMB, sys.SwapTotalMB))
|
|
||||||
}
|
|
||||||
b.WriteString(fmt.Sprintf(" Uptime: %s\n", sys.UptimeSince))
|
|
||||||
if sys.OOMKills > 0 {
|
|
||||||
b.WriteString(fmt.Sprintf(" OOM: %s\n", styleCritical.Render(fmt.Sprintf("%d kills", sys.OOMKills))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Services
|
|
||||||
if r.Services != nil && len(r.Services.Services) > 0 {
|
|
||||||
b.WriteString(styleBold.Render(" Services"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
for _, svc := range r.Services.Services {
|
|
||||||
stateStr := styleHealthy.Render(svc.ActiveState)
|
|
||||||
if svc.ActiveState == "failed" {
|
|
||||||
stateStr = styleCritical.Render("FAILED")
|
|
||||||
} else if svc.ActiveState != "active" {
|
|
||||||
stateStr = styleWarning.Render(svc.ActiveState)
|
|
||||||
}
|
|
||||||
extra := ""
|
|
||||||
if svc.MemoryCurrentMB > 0 {
|
|
||||||
extra += fmt.Sprintf(" mem=%dMB", svc.MemoryCurrentMB)
|
|
||||||
}
|
|
||||||
if svc.NRestarts > 0 {
|
|
||||||
extra += fmt.Sprintf(" restarts=%d", svc.NRestarts)
|
|
||||||
}
|
|
||||||
if svc.RestartLoopRisk {
|
|
||||||
extra += styleCritical.Render(" RESTART-LOOP")
|
|
||||||
}
|
|
||||||
b.WriteString(fmt.Sprintf(" %-28s %s%s\n", svc.Name, stateStr, extra))
|
|
||||||
}
|
|
||||||
if len(r.Services.FailedUnits) > 0 {
|
|
||||||
b.WriteString(fmt.Sprintf(" Failed units: %s\n",
|
|
||||||
styleCritical.Render(strings.Join(r.Services.FailedUnits, ", "))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RQLite
|
|
||||||
if r.RQLite != nil {
|
|
||||||
rq := r.RQLite
|
|
||||||
b.WriteString(styleBold.Render(" RQLite"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(fmt.Sprintf(" Responsive: %s Ready: %s Strong Read: %s\n",
|
|
||||||
statusStr(rq.Responsive), statusStr(rq.Ready), statusStr(rq.StrongRead)))
|
|
||||||
if rq.Responsive {
|
|
||||||
b.WriteString(fmt.Sprintf(" Raft: %s Leader: %s Term: %d Applied: %d\n",
|
|
||||||
styleBold.Render(rq.RaftState), rq.LeaderAddr, rq.Term, rq.Applied))
|
|
||||||
if rq.DBSize != "" {
|
|
||||||
b.WriteString(fmt.Sprintf(" DB size: %s Peers: %d Goroutines: %d Heap: %dMB\n",
|
|
||||||
rq.DBSize, rq.NumPeers, rq.Goroutines, rq.HeapMB))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WireGuard
|
|
||||||
if r.WireGuard != nil {
|
|
||||||
wg := r.WireGuard
|
|
||||||
b.WriteString(styleBold.Render(" WireGuard"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(fmt.Sprintf(" Interface: %s IP: %s Peers: %d\n",
|
|
||||||
statusStr(wg.InterfaceUp), wg.WgIP, wg.PeerCount))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network
|
|
||||||
if r.Network != nil {
|
|
||||||
net := r.Network
|
|
||||||
b.WriteString(styleBold.Render(" Network"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(fmt.Sprintf(" Internet: %s UFW: %s TCP est: %d retrans: %.1f%%\n",
|
|
||||||
statusStr(net.InternetReachable), statusStr(net.UFWActive),
|
|
||||||
net.TCPEstablished, net.TCPRetransRate))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// renderOverview renders the Overview tab: cluster summary, node table, alert summary.
|
|
||||||
func renderOverview(snap *monitor.ClusterSnapshot, width int) string {
|
|
||||||
if snap == nil {
|
|
||||||
return styleMuted.Render("Collecting cluster data...")
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
// -- Cluster Summary --
|
|
||||||
b.WriteString(styleBold.Render("Cluster Summary"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(separator(width))
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
healthy := snap.HealthyCount()
|
|
||||||
total := snap.TotalCount()
|
|
||||||
failed := total - healthy
|
|
||||||
|
|
||||||
healthColor := styleHealthy
|
|
||||||
if failed > 0 {
|
|
||||||
healthColor = styleWarning
|
|
||||||
}
|
|
||||||
if healthy == 0 && total > 0 {
|
|
||||||
healthColor = styleCritical
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString(fmt.Sprintf(" Environment: %s\n", styleBold.Render(snap.Environment)))
|
|
||||||
b.WriteString(fmt.Sprintf(" Nodes: %s / %d\n", healthColor.Render(fmt.Sprintf("%d healthy", healthy)), total))
|
|
||||||
if failed > 0 {
|
|
||||||
b.WriteString(fmt.Sprintf(" Failed: %s\n", styleCritical.Render(fmt.Sprintf("%d", failed))))
|
|
||||||
}
|
|
||||||
b.WriteString(fmt.Sprintf(" Collect time: %s\n", styleMuted.Render(snap.Duration.Truncate(1e6).String())))
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// -- Node Table --
|
|
||||||
b.WriteString(styleBold.Render("Nodes"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(separator(width))
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// Header row
|
|
||||||
b.WriteString(fmt.Sprintf(" %-18s %-8s %-10s %-8s %-8s %-8s %-10s\n",
|
|
||||||
headerStyle.Render("HOST"),
|
|
||||||
headerStyle.Render("STATUS"),
|
|
||||||
headerStyle.Render("ROLE"),
|
|
||||||
headerStyle.Render("CPU"),
|
|
||||||
headerStyle.Render("MEM%"),
|
|
||||||
headerStyle.Render("DISK%"),
|
|
||||||
headerStyle.Render("RQLITE"),
|
|
||||||
))
|
|
||||||
|
|
||||||
for _, cs := range snap.Nodes {
|
|
||||||
if cs.Error != nil {
|
|
||||||
b.WriteString(fmt.Sprintf(" %-18s %s %s\n",
|
|
||||||
cs.Node.Host,
|
|
||||||
styleCritical.Render("FAIL"),
|
|
||||||
styleMuted.Render(truncateStr(cs.Error.Error(), 40)),
|
|
||||||
))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r := cs.Report
|
|
||||||
if r == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
host := r.PublicIP
|
|
||||||
if host == "" {
|
|
||||||
host = r.Hostname
|
|
||||||
}
|
|
||||||
|
|
||||||
var status string
|
|
||||||
if cs.Error == nil && r != nil {
|
|
||||||
status = styleHealthy.Render("OK")
|
|
||||||
} else {
|
|
||||||
status = styleCritical.Render("FAIL")
|
|
||||||
}
|
|
||||||
|
|
||||||
role := cs.Node.Role
|
|
||||||
if role == "" {
|
|
||||||
role = "node"
|
|
||||||
}
|
|
||||||
|
|
||||||
cpuStr := "-"
|
|
||||||
memStr := "-"
|
|
||||||
diskStr := "-"
|
|
||||||
if r.System != nil {
|
|
||||||
cpuStr = fmt.Sprintf("%.1f", r.System.LoadAvg1)
|
|
||||||
memStr = colorPct(r.System.MemUsePct)
|
|
||||||
diskStr = colorPct(r.System.DiskUsePct)
|
|
||||||
}
|
|
||||||
|
|
||||||
rqliteStr := "-"
|
|
||||||
if r.RQLite != nil {
|
|
||||||
if r.RQLite.Responsive {
|
|
||||||
rqliteStr = styleHealthy.Render(r.RQLite.RaftState)
|
|
||||||
} else {
|
|
||||||
rqliteStr = styleCritical.Render("DOWN")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString(fmt.Sprintf(" %-18s %-8s %-10s %-8s %-8s %-8s %-10s\n",
|
|
||||||
host, status, role, cpuStr, memStr, diskStr, rqliteStr))
|
|
||||||
}
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// -- Alert Summary --
|
|
||||||
critCount, warnCount, infoCount := countAlertsBySeverity(snap.Alerts)
|
|
||||||
b.WriteString(styleBold.Render("Alerts"))
|
|
||||||
b.WriteString(fmt.Sprintf(" %s %s %s\n",
|
|
||||||
styleCritical.Render(fmt.Sprintf("%d critical", critCount)),
|
|
||||||
styleWarning.Render(fmt.Sprintf("%d warning", warnCount)),
|
|
||||||
styleMuted.Render(fmt.Sprintf("%d info", infoCount)),
|
|
||||||
))
|
|
||||||
|
|
||||||
if critCount > 0 {
|
|
||||||
b.WriteString("\n")
|
|
||||||
for _, a := range snap.Alerts {
|
|
||||||
if a.Severity == monitor.AlertCritical {
|
|
||||||
b.WriteString(fmt.Sprintf(" %s [%s] %s: %s\n",
|
|
||||||
styleCritical.Render("CRIT"),
|
|
||||||
a.Subsystem,
|
|
||||||
a.Node,
|
|
||||||
a.Message,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// colorPct returns a percentage string colored by threshold.
|
|
||||||
func colorPct(pct int) string {
|
|
||||||
s := fmt.Sprintf("%d%%", pct)
|
|
||||||
switch {
|
|
||||||
case pct >= 90:
|
|
||||||
return styleCritical.Render(s)
|
|
||||||
case pct >= 75:
|
|
||||||
return styleWarning.Render(s)
|
|
||||||
default:
|
|
||||||
return styleHealthy.Render(s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// countAlertsBySeverity counts alerts by severity level.
|
|
||||||
func countAlertsBySeverity(alerts []monitor.Alert) (crit, warn, info int) {
|
|
||||||
for _, a := range alerts {
|
|
||||||
switch a.Severity {
|
|
||||||
case monitor.AlertCritical:
|
|
||||||
crit++
|
|
||||||
case monitor.AlertWarning:
|
|
||||||
warn++
|
|
||||||
case monitor.AlertInfo:
|
|
||||||
info++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// truncateStr truncates a string to maxLen characters.
|
|
||||||
func truncateStr(s string, maxLen int) string {
|
|
||||||
if len(s) <= maxLen {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:maxLen] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
// separator returns a dashed line of the given width.
|
|
||||||
func separator(width int) string {
|
|
||||||
if width <= 0 {
|
|
||||||
width = 80
|
|
||||||
}
|
|
||||||
return styleMuted.Render(strings.Repeat("\u2500", width))
|
|
||||||
}
|
|
||||||
@ -1,133 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
// renderServicesTab renders a cross-node service matrix.
|
|
||||||
func renderServicesTab(snap *monitor.ClusterSnapshot, width int) string {
|
|
||||||
if snap == nil {
|
|
||||||
return styleMuted.Render("Collecting cluster data...")
|
|
||||||
}
|
|
||||||
|
|
||||||
reports := snap.Healthy()
|
|
||||||
if len(reports) == 0 {
|
|
||||||
return styleMuted.Render("No healthy nodes to display.")
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
// Collect all unique service names across nodes
|
|
||||||
svcSet := make(map[string]bool)
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.Services == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, svc := range r.Services.Services {
|
|
||||||
svcSet[svc.Name] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
svcNames := make([]string, 0, len(svcSet))
|
|
||||||
for name := range svcSet {
|
|
||||||
svcNames = append(svcNames, name)
|
|
||||||
}
|
|
||||||
sort.Strings(svcNames)
|
|
||||||
|
|
||||||
if len(svcNames) == 0 {
|
|
||||||
return styleMuted.Render("No services found on any node.")
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString(styleBold.Render("Service Matrix"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(separator(width))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
// Header: service name + each node host
|
|
||||||
header := fmt.Sprintf(" %-28s", headerStyle.Render("SERVICE"))
|
|
||||||
for _, r := range reports {
|
|
||||||
host := nodeHost(r)
|
|
||||||
if len(host) > 15 {
|
|
||||||
host = host[:15]
|
|
||||||
}
|
|
||||||
header += fmt.Sprintf(" %-17s", headerStyle.Render(host))
|
|
||||||
}
|
|
||||||
b.WriteString(header)
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
// Build a lookup: host -> service name -> ServiceInfo
|
|
||||||
type svcKey struct {
|
|
||||||
host string
|
|
||||||
name string
|
|
||||||
}
|
|
||||||
svcMap := make(map[svcKey]string) // status string
|
|
||||||
for _, r := range reports {
|
|
||||||
host := nodeHost(r)
|
|
||||||
if r.Services == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, svc := range r.Services.Services {
|
|
||||||
var st string
|
|
||||||
switch {
|
|
||||||
case svc.ActiveState == "active":
|
|
||||||
st = styleHealthy.Render("active")
|
|
||||||
case svc.ActiveState == "failed":
|
|
||||||
st = styleCritical.Render("FAILED")
|
|
||||||
case svc.ActiveState == "":
|
|
||||||
st = styleMuted.Render("n/a")
|
|
||||||
default:
|
|
||||||
st = styleWarning.Render(svc.ActiveState)
|
|
||||||
}
|
|
||||||
if svc.RestartLoopRisk {
|
|
||||||
st = styleCritical.Render("LOOP!")
|
|
||||||
}
|
|
||||||
svcMap[svcKey{host, svc.Name}] = st
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rows
|
|
||||||
for _, svcName := range svcNames {
|
|
||||||
row := fmt.Sprintf(" %-28s", svcName)
|
|
||||||
for _, r := range reports {
|
|
||||||
host := nodeHost(r)
|
|
||||||
st, ok := svcMap[svcKey{host, svcName}]
|
|
||||||
if !ok {
|
|
||||||
st = styleMuted.Render("-")
|
|
||||||
}
|
|
||||||
row += fmt.Sprintf(" %-17s", st)
|
|
||||||
}
|
|
||||||
b.WriteString(row)
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Failed units per node
|
|
||||||
hasFailedUnits := false
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.Services != nil && len(r.Services.FailedUnits) > 0 {
|
|
||||||
hasFailedUnits = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasFailedUnits {
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(styleBold.Render("Failed Systemd Units"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(separator(width))
|
|
||||||
b.WriteString("\n")
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.Services == nil || len(r.Services.FailedUnits) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b.WriteString(fmt.Sprintf(" %s: %s\n",
|
|
||||||
styleBold.Render(nodeHost(r)),
|
|
||||||
styleCritical.Render(strings.Join(r.Services.FailedUnits, ", ")),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/production/report"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
colorGreen = lipgloss.Color("#00ff00")
|
|
||||||
colorRed = lipgloss.Color("#ff0000")
|
|
||||||
colorYellow = lipgloss.Color("#ffff00")
|
|
||||||
colorMuted = lipgloss.Color("#888888")
|
|
||||||
colorWhite = lipgloss.Color("#ffffff")
|
|
||||||
colorBg = lipgloss.Color("#1a1a2e")
|
|
||||||
|
|
||||||
styleHealthy = lipgloss.NewStyle().Foreground(colorGreen)
|
|
||||||
styleWarning = lipgloss.NewStyle().Foreground(colorYellow)
|
|
||||||
styleCritical = lipgloss.NewStyle().Foreground(colorRed)
|
|
||||||
styleMuted = lipgloss.NewStyle().Foreground(colorMuted)
|
|
||||||
styleBold = lipgloss.NewStyle().Bold(true)
|
|
||||||
|
|
||||||
activeTab = lipgloss.NewStyle().Bold(true).Foreground(colorWhite).Background(lipgloss.Color("#333333")).Padding(0, 1)
|
|
||||||
inactiveTab = lipgloss.NewStyle().Foreground(colorMuted).Padding(0, 1)
|
|
||||||
|
|
||||||
headerStyle = lipgloss.NewStyle().Bold(true).Foreground(colorWhite)
|
|
||||||
footerStyle = lipgloss.NewStyle().Foreground(colorMuted)
|
|
||||||
)
|
|
||||||
|
|
||||||
// statusStr returns a green "OK" when ok is true, red "DOWN" when false.
|
|
||||||
func statusStr(ok bool) string {
|
|
||||||
if ok {
|
|
||||||
return styleHealthy.Render("OK")
|
|
||||||
}
|
|
||||||
return styleCritical.Render("DOWN")
|
|
||||||
}
|
|
||||||
|
|
||||||
// severityStyle returns the appropriate lipgloss style for an alert severity.
|
|
||||||
func severityStyle(s string) lipgloss.Style {
|
|
||||||
switch s {
|
|
||||||
case "critical":
|
|
||||||
return styleCritical
|
|
||||||
case "warning":
|
|
||||||
return styleWarning
|
|
||||||
case "info":
|
|
||||||
return styleMuted
|
|
||||||
default:
|
|
||||||
return styleMuted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// nodeHost returns the best display host for a NodeReport.
|
|
||||||
func nodeHost(r *report.NodeReport) string {
|
|
||||||
if r.PublicIP != "" {
|
|
||||||
return r.PublicIP
|
|
||||||
}
|
|
||||||
return r.Hostname
|
|
||||||
}
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import "strings"
|
|
||||||
|
|
||||||
// renderTabBar renders the tab bar with the active tab highlighted.
|
|
||||||
func renderTabBar(active int, width int) string {
|
|
||||||
var parts []string
|
|
||||||
for i, name := range tabNames {
|
|
||||||
if i == active {
|
|
||||||
parts = append(parts, activeTab.Render(name))
|
|
||||||
} else {
|
|
||||||
parts = append(parts, inactiveTab.Render(name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bar := strings.Join(parts, styleMuted.Render(" | "))
|
|
||||||
|
|
||||||
// Pad to full width if needed
|
|
||||||
if width > 0 {
|
|
||||||
rendered := stripAnsi(bar)
|
|
||||||
if len(rendered) < width {
|
|
||||||
bar += strings.Repeat(" ", width-len(rendered))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bar
|
|
||||||
}
|
|
||||||
|
|
||||||
// stripAnsi removes ANSI escape codes for length calculation.
|
|
||||||
func stripAnsi(s string) string {
|
|
||||||
var out []byte
|
|
||||||
inEsc := false
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
if s[i] == '\x1b' {
|
|
||||||
inEsc = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if inEsc {
|
|
||||||
if (s[i] >= 'a' && s[i] <= 'z') || (s[i] >= 'A' && s[i] <= 'Z') {
|
|
||||||
inEsc = false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, s[i])
|
|
||||||
}
|
|
||||||
return string(out)
|
|
||||||
}
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/monitor"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/production/report"
|
|
||||||
)
|
|
||||||
|
|
||||||
// renderWGMesh renders the WireGuard mesh status tab with peer details.
|
|
||||||
func renderWGMesh(snap *monitor.ClusterSnapshot, width int) string {
|
|
||||||
if snap == nil {
|
|
||||||
return styleMuted.Render("Collecting cluster data...")
|
|
||||||
}
|
|
||||||
|
|
||||||
reports := snap.Healthy()
|
|
||||||
if len(reports) == 0 {
|
|
||||||
return styleMuted.Render("No healthy nodes to display.")
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
// Mesh overview
|
|
||||||
b.WriteString(styleBold.Render("WireGuard Mesh Overview"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(separator(width))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
|
|
||||||
// Summary header
|
|
||||||
b.WriteString(fmt.Sprintf(" %-18s %-10s %-18s %-6s %-8s\n",
|
|
||||||
headerStyle.Render("HOST"),
|
|
||||||
headerStyle.Render("IFACE"),
|
|
||||||
headerStyle.Render("WG IP"),
|
|
||||||
headerStyle.Render("PEERS"),
|
|
||||||
headerStyle.Render("PORT"),
|
|
||||||
))
|
|
||||||
|
|
||||||
wgNodes := 0
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.WireGuard == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
wgNodes++
|
|
||||||
wg := r.WireGuard
|
|
||||||
ifaceStr := statusStr(wg.InterfaceUp)
|
|
||||||
b.WriteString(fmt.Sprintf(" %-18s %-10s %-18s %-6d %-8d\n",
|
|
||||||
nodeHost(r), ifaceStr, wg.WgIP, wg.PeerCount, wg.ListenPort))
|
|
||||||
}
|
|
||||||
|
|
||||||
if wgNodes == 0 {
|
|
||||||
return styleMuted.Render("No nodes have WireGuard configured.")
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedPeers := wgNodes - 1
|
|
||||||
|
|
||||||
// Per-node peer details
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(styleBold.Render("Peer Details"))
|
|
||||||
b.WriteString("\n")
|
|
||||||
b.WriteString(separator(width))
|
|
||||||
b.WriteString("\n")
|
|
||||||
|
|
||||||
for _, r := range reports {
|
|
||||||
if r.WireGuard == nil || len(r.WireGuard.Peers) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n")
|
|
||||||
host := nodeHost(r)
|
|
||||||
peerCountStr := fmt.Sprintf("%d/%d peers", len(r.WireGuard.Peers), expectedPeers)
|
|
||||||
if len(r.WireGuard.Peers) < expectedPeers {
|
|
||||||
peerCountStr = styleCritical.Render(peerCountStr)
|
|
||||||
} else {
|
|
||||||
peerCountStr = styleHealthy.Render(peerCountStr)
|
|
||||||
}
|
|
||||||
b.WriteString(fmt.Sprintf(" %s %s\n", styleBold.Render(host), peerCountStr))
|
|
||||||
|
|
||||||
for _, p := range r.WireGuard.Peers {
|
|
||||||
b.WriteString(renderPeerLine(p))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderPeerLine formats a single WG peer.
|
|
||||||
func renderPeerLine(p report.WGPeerInfo) string {
|
|
||||||
keyShort := p.PublicKey
|
|
||||||
if len(keyShort) > 12 {
|
|
||||||
keyShort = keyShort[:12] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handshake status
|
|
||||||
var hsStr string
|
|
||||||
if p.LatestHandshake == 0 {
|
|
||||||
hsStr = styleCritical.Render("never")
|
|
||||||
} else if p.HandshakeAgeSec > 180 {
|
|
||||||
hsStr = styleWarning.Render(fmt.Sprintf("%ds ago", p.HandshakeAgeSec))
|
|
||||||
} else {
|
|
||||||
hsStr = styleHealthy.Render(fmt.Sprintf("%ds ago", p.HandshakeAgeSec))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transfer
|
|
||||||
rx := formatBytes(p.TransferRx)
|
|
||||||
tx := formatBytes(p.TransferTx)
|
|
||||||
|
|
||||||
return fmt.Sprintf(" key=%s endpoint=%-22s hs=%s rx=%s tx=%s ips=%s\n",
|
|
||||||
styleMuted.Render(keyShort),
|
|
||||||
p.Endpoint,
|
|
||||||
hsStr,
|
|
||||||
rx, tx,
|
|
||||||
p.AllowedIPs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatBytes formats bytes into a human-readable string.
|
|
||||||
func formatBytes(b int64) string {
|
|
||||||
switch {
|
|
||||||
case b >= 1<<30:
|
|
||||||
return fmt.Sprintf("%.1fGB", float64(b)/(1<<30))
|
|
||||||
case b >= 1<<20:
|
|
||||||
return fmt.Sprintf("%.1fMB", float64(b)/(1<<20))
|
|
||||||
case b >= 1<<10:
|
|
||||||
return fmt.Sprintf("%.1fKB", float64(b)/(1<<10))
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%dB", b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,495 +0,0 @@
|
|||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/auth"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/constants"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HandleNamespaceCommand handles namespace management commands
|
|
||||||
func HandleNamespaceCommand(args []string) {
|
|
||||||
if len(args) == 0 {
|
|
||||||
showNamespaceHelp()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subcommand := args[0]
|
|
||||||
switch subcommand {
|
|
||||||
case "delete":
|
|
||||||
var force bool
|
|
||||||
fs := flag.NewFlagSet("namespace delete", flag.ExitOnError)
|
|
||||||
fs.BoolVar(&force, "force", false, "Skip confirmation prompt")
|
|
||||||
_ = fs.Parse(args[1:])
|
|
||||||
handleNamespaceDelete(force)
|
|
||||||
case "list":
|
|
||||||
handleNamespaceList()
|
|
||||||
case "repair":
|
|
||||||
if len(args) < 2 {
|
|
||||||
fmt.Fprintf(os.Stderr, "Usage: orama namespace repair <namespace_name>\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
handleNamespaceRepair(args[1])
|
|
||||||
case "enable":
|
|
||||||
if len(args) < 2 {
|
|
||||||
fmt.Fprintf(os.Stderr, "Usage: orama namespace enable <feature> --namespace <name>\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "Features: webrtc\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
handleNamespaceEnable(args[1:])
|
|
||||||
case "disable":
|
|
||||||
if len(args) < 2 {
|
|
||||||
fmt.Fprintf(os.Stderr, "Usage: orama namespace disable <feature> --namespace <name>\n")
|
|
||||||
fmt.Fprintf(os.Stderr, "Features: webrtc\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
handleNamespaceDisable(args[1:])
|
|
||||||
case "webrtc-status":
|
|
||||||
var ns string
|
|
||||||
fs := flag.NewFlagSet("namespace webrtc-status", flag.ExitOnError)
|
|
||||||
fs.StringVar(&ns, "namespace", "", "Namespace name")
|
|
||||||
_ = fs.Parse(args[1:])
|
|
||||||
if ns == "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "Usage: orama namespace webrtc-status --namespace <name>\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
handleNamespaceWebRTCStatus(ns)
|
|
||||||
case "help":
|
|
||||||
showNamespaceHelp()
|
|
||||||
default:
|
|
||||||
fmt.Fprintf(os.Stderr, "Unknown namespace command: %s\n", subcommand)
|
|
||||||
showNamespaceHelp()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func showNamespaceHelp() {
|
|
||||||
fmt.Printf("Namespace Management Commands\n\n")
|
|
||||||
fmt.Printf("Usage: orama namespace <subcommand>\n\n")
|
|
||||||
fmt.Printf("Subcommands:\n")
|
|
||||||
fmt.Printf(" list - List namespaces owned by the current wallet\n")
|
|
||||||
fmt.Printf(" delete - Delete the current namespace and all its resources\n")
|
|
||||||
fmt.Printf(" repair <namespace> - Repair an under-provisioned namespace cluster\n")
|
|
||||||
fmt.Printf(" enable webrtc --namespace NS - Enable WebRTC (SFU + TURN) for a namespace\n")
|
|
||||||
fmt.Printf(" disable webrtc --namespace NS - Disable WebRTC for a namespace\n")
|
|
||||||
fmt.Printf(" webrtc-status --namespace NS - Show WebRTC service status\n")
|
|
||||||
fmt.Printf(" help - Show this help message\n\n")
|
|
||||||
fmt.Printf("Flags:\n")
|
|
||||||
fmt.Printf(" --force - Skip confirmation prompt (delete only)\n")
|
|
||||||
fmt.Printf(" --namespace - Namespace name (enable/disable/webrtc-status)\n\n")
|
|
||||||
fmt.Printf("Examples:\n")
|
|
||||||
fmt.Printf(" orama namespace list\n")
|
|
||||||
fmt.Printf(" orama namespace delete\n")
|
|
||||||
fmt.Printf(" orama namespace delete --force\n")
|
|
||||||
fmt.Printf(" orama namespace repair anchat\n")
|
|
||||||
fmt.Printf(" orama namespace enable webrtc --namespace myapp\n")
|
|
||||||
fmt.Printf(" orama namespace disable webrtc --namespace myapp\n")
|
|
||||||
fmt.Printf(" orama namespace webrtc-status --namespace myapp\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleNamespaceRepair(namespaceName string) {
|
|
||||||
fmt.Printf("Repairing namespace cluster '%s'...\n", namespaceName)
|
|
||||||
|
|
||||||
// Call the internal repair endpoint on the local gateway
|
|
||||||
url := fmt.Sprintf("http://localhost:%d/v1/internal/namespace/repair?namespace=%s", constants.GatewayAPIPort, namespaceName)
|
|
||||||
req, err := http.NewRequest(http.MethodPost, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
req.Header.Set("X-Orama-Internal-Auth", "namespace-coordination")
|
|
||||||
|
|
||||||
client := &http.Client{}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to connect to local gateway (is the node running?): %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := result["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "Repair failed: %s\n", errMsg)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Namespace '%s' cluster repaired successfully.\n", namespaceName)
|
|
||||||
if msg, ok := result["message"].(string); ok {
|
|
||||||
fmt.Printf(" %s\n", msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleNamespaceDelete(force bool) {
|
|
||||||
// Load credentials
|
|
||||||
store, err := auth.LoadEnhancedCredentials()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to load credentials: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
gatewayURL := getGatewayURL()
|
|
||||||
creds := store.GetDefaultCredential(gatewayURL)
|
|
||||||
|
|
||||||
if creds == nil || !creds.IsValid() {
|
|
||||||
fmt.Fprintf(os.Stderr, "Not authenticated. Run 'orama auth login' first.\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
namespace := creds.Namespace
|
|
||||||
if namespace == "" || namespace == "default" {
|
|
||||||
fmt.Fprintf(os.Stderr, "Cannot delete default namespace.\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm deletion
|
|
||||||
if !force {
|
|
||||||
fmt.Printf("This will permanently delete namespace '%s' and all its resources:\n", namespace)
|
|
||||||
fmt.Printf(" - All deployments and their processes\n")
|
|
||||||
fmt.Printf(" - RQLite cluster (3 nodes)\n")
|
|
||||||
fmt.Printf(" - Olric cache cluster (3 nodes)\n")
|
|
||||||
fmt.Printf(" - Gateway instances\n")
|
|
||||||
fmt.Printf(" - API keys and credentials\n")
|
|
||||||
fmt.Printf(" - IPFS content and DNS records\n\n")
|
|
||||||
fmt.Printf("Type the namespace name to confirm: ")
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
|
||||||
scanner.Scan()
|
|
||||||
input := strings.TrimSpace(scanner.Text())
|
|
||||||
|
|
||||||
if input != namespace {
|
|
||||||
fmt.Println("Aborted - namespace name did not match.")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Deleting namespace '%s'...\n", namespace)
|
|
||||||
|
|
||||||
// Make DELETE request to gateway
|
|
||||||
url := fmt.Sprintf("%s/v1/namespace/delete", gatewayURL)
|
|
||||||
req, err := http.NewRequest(http.MethodDelete, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+creds.APIKey)
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Transport: &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to connect to gateway: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := result["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to delete namespace: %s\n", errMsg)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Namespace '%s' deleted successfully.\n", namespace)
|
|
||||||
|
|
||||||
// Clean up local credentials for the deleted namespace
|
|
||||||
if store.RemoveCredentialByNamespace(gatewayURL, namespace) {
|
|
||||||
if err := store.Save(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Warning: failed to clean up local credentials: %v\n", err)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Local credentials for '%s' cleared.\n", namespace)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Run 'orama auth login' to create a new namespace.\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleNamespaceEnable(args []string) {
|
|
||||||
feature := args[0]
|
|
||||||
if feature != "webrtc" {
|
|
||||||
fmt.Fprintf(os.Stderr, "Unknown feature: %s\nSupported features: webrtc\n", feature)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
var ns string
|
|
||||||
fs := flag.NewFlagSet("namespace enable webrtc", flag.ExitOnError)
|
|
||||||
fs.StringVar(&ns, "namespace", "", "Namespace name")
|
|
||||||
_ = fs.Parse(args[1:])
|
|
||||||
|
|
||||||
if ns == "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "Usage: orama namespace enable webrtc --namespace <name>\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
gatewayURL, apiKey := loadAuthForNamespace(ns)
|
|
||||||
|
|
||||||
fmt.Printf("Enabling WebRTC for namespace '%s'...\n", ns)
|
|
||||||
fmt.Printf("This will provision SFU (3 nodes) and TURN (2 nodes) services.\n")
|
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/v1/namespace/webrtc/enable", gatewayURL)
|
|
||||||
req, err := http.NewRequest(http.MethodPost, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Transport: &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to connect to gateway: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := result["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to enable WebRTC: %s\n", errMsg)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("WebRTC enabled for namespace '%s'.\n", ns)
|
|
||||||
fmt.Printf(" SFU instances: 3 nodes (signaling via WireGuard)\n")
|
|
||||||
fmt.Printf(" TURN instances: 2 nodes (relay on public IPs)\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleNamespaceDisable(args []string) {
|
|
||||||
feature := args[0]
|
|
||||||
if feature != "webrtc" {
|
|
||||||
fmt.Fprintf(os.Stderr, "Unknown feature: %s\nSupported features: webrtc\n", feature)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
var ns string
|
|
||||||
fs := flag.NewFlagSet("namespace disable webrtc", flag.ExitOnError)
|
|
||||||
fs.StringVar(&ns, "namespace", "", "Namespace name")
|
|
||||||
_ = fs.Parse(args[1:])
|
|
||||||
|
|
||||||
if ns == "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "Usage: orama namespace disable webrtc --namespace <name>\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
gatewayURL, apiKey := loadAuthForNamespace(ns)
|
|
||||||
|
|
||||||
fmt.Printf("Disabling WebRTC for namespace '%s'...\n", ns)
|
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/v1/namespace/webrtc/disable", gatewayURL)
|
|
||||||
req, err := http.NewRequest(http.MethodPost, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Transport: &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to connect to gateway: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := result["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to disable WebRTC: %s\n", errMsg)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("WebRTC disabled for namespace '%s'.\n", ns)
|
|
||||||
fmt.Printf(" SFU and TURN services stopped, ports deallocated, DNS records removed.\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleNamespaceWebRTCStatus(ns string) {
|
|
||||||
gatewayURL, apiKey := loadAuthForNamespace(ns)
|
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/v1/namespace/webrtc/status", gatewayURL)
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Transport: &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to connect to gateway: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := result["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to get WebRTC status: %s\n", errMsg)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
enabled, _ := result["enabled"].(bool)
|
|
||||||
if !enabled {
|
|
||||||
fmt.Printf("WebRTC is not enabled for namespace '%s'.\n", ns)
|
|
||||||
fmt.Printf(" Enable with: orama namespace enable webrtc --namespace %s\n", ns)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("WebRTC Status for namespace '%s'\n\n", ns)
|
|
||||||
fmt.Printf(" Enabled: yes\n")
|
|
||||||
if sfuCount, ok := result["sfu_node_count"].(float64); ok {
|
|
||||||
fmt.Printf(" SFU nodes: %.0f\n", sfuCount)
|
|
||||||
}
|
|
||||||
if turnCount, ok := result["turn_node_count"].(float64); ok {
|
|
||||||
fmt.Printf(" TURN nodes: %.0f\n", turnCount)
|
|
||||||
}
|
|
||||||
if ttl, ok := result["turn_credential_ttl"].(float64); ok {
|
|
||||||
fmt.Printf(" TURN cred TTL: %.0fs\n", ttl)
|
|
||||||
}
|
|
||||||
if enabledBy, ok := result["enabled_by"].(string); ok {
|
|
||||||
fmt.Printf(" Enabled by: %s\n", enabledBy)
|
|
||||||
}
|
|
||||||
if enabledAt, ok := result["enabled_at"].(string); ok {
|
|
||||||
fmt.Printf(" Enabled at: %s\n", enabledAt)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadAuthForNamespace loads credentials and returns the gateway URL and API key.
|
|
||||||
// Exits with an error message if not authenticated.
|
|
||||||
func loadAuthForNamespace(ns string) (gatewayURL, apiKey string) {
|
|
||||||
store, err := auth.LoadEnhancedCredentials()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to load credentials: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
gatewayURL = getGatewayURL()
|
|
||||||
creds := store.GetDefaultCredential(gatewayURL)
|
|
||||||
|
|
||||||
if creds == nil || !creds.IsValid() {
|
|
||||||
fmt.Fprintf(os.Stderr, "Not authenticated. Run 'orama auth login' first.\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return gatewayURL, creds.APIKey
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleNamespaceList() {
|
|
||||||
// Load credentials
|
|
||||||
store, err := auth.LoadEnhancedCredentials()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to load credentials: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
gatewayURL := getGatewayURL()
|
|
||||||
creds := store.GetDefaultCredential(gatewayURL)
|
|
||||||
|
|
||||||
if creds == nil || !creds.IsValid() {
|
|
||||||
fmt.Fprintf(os.Stderr, "Not authenticated. Run 'orama auth login' first.\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make GET request to namespace list endpoint
|
|
||||||
url := fmt.Sprintf("%s/v1/namespace/list", gatewayURL)
|
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to create request: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+creds.APIKey)
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Transport: &http.Transport{
|
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to connect to gateway: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var result map[string]interface{}
|
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
errMsg := "unknown error"
|
|
||||||
if e, ok := result["error"].(string); ok {
|
|
||||||
errMsg = e
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "Failed to list namespaces: %s\n", errMsg)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
namespaces, _ := result["namespaces"].([]interface{})
|
|
||||||
if len(namespaces) == 0 {
|
|
||||||
fmt.Println("No namespaces found.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
activeNS := creds.Namespace
|
|
||||||
|
|
||||||
fmt.Printf("Namespaces (%d):\n\n", len(namespaces))
|
|
||||||
for _, ns := range namespaces {
|
|
||||||
nsMap, _ := ns.(map[string]interface{})
|
|
||||||
name, _ := nsMap["name"].(string)
|
|
||||||
status, _ := nsMap["cluster_status"].(string)
|
|
||||||
|
|
||||||
marker := " "
|
|
||||||
if name == activeNS {
|
|
||||||
marker = "* "
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("%s%-20s cluster: %s\n", marker, name, status)
|
|
||||||
}
|
|
||||||
fmt.Printf("\n* = active namespace\n")
|
|
||||||
}
|
|
||||||
@ -1,189 +0,0 @@
|
|||||||
package clean
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Flags holds clean command flags.
|
|
||||||
type Flags struct {
|
|
||||||
Env string // Target environment
|
|
||||||
Node string // Single node IP
|
|
||||||
Nuclear bool // Also remove shared binaries
|
|
||||||
Force bool // Skip confirmation
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle is the entry point for the clean command.
|
|
||||||
func Handle(args []string) {
|
|
||||||
flags, err := parseFlags(args)
|
|
||||||
if err != nil {
|
|
||||||
if err == flag.ErrHelp {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := execute(flags); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFlags(args []string) (*Flags, error) {
|
|
||||||
fs := flag.NewFlagSet("clean", flag.ContinueOnError)
|
|
||||||
fs.SetOutput(os.Stderr)
|
|
||||||
|
|
||||||
flags := &Flags{}
|
|
||||||
fs.StringVar(&flags.Env, "env", "", "Target environment (devnet, testnet) [required]")
|
|
||||||
fs.StringVar(&flags.Node, "node", "", "Clean a single node IP only")
|
|
||||||
fs.BoolVar(&flags.Nuclear, "nuclear", false, "Also remove shared binaries (rqlited, ipfs, caddy, etc.)")
|
|
||||||
fs.BoolVar(&flags.Force, "force", false, "Skip confirmation (DESTRUCTIVE)")
|
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if flags.Env == "" {
|
|
||||||
return nil, fmt.Errorf("--env is required\nUsage: orama node clean --env <devnet|testnet> --force")
|
|
||||||
}
|
|
||||||
|
|
||||||
return flags, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func execute(flags *Flags) error {
|
|
||||||
nodes, err := remotessh.LoadEnvNodes(flags.Env)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup, err := remotessh.PrepareNodeKeys(nodes)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
if flags.Node != "" {
|
|
||||||
nodes = remotessh.FilterByIP(nodes, flags.Node)
|
|
||||||
if len(nodes) == 0 {
|
|
||||||
return fmt.Errorf("node %s not found in %s environment", flags.Node, flags.Env)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Clean %s: %d node(s)\n", flags.Env, len(nodes))
|
|
||||||
if flags.Nuclear {
|
|
||||||
fmt.Printf(" Mode: NUCLEAR (removes binaries too)\n")
|
|
||||||
}
|
|
||||||
for _, n := range nodes {
|
|
||||||
fmt.Printf(" - %s (%s)\n", n.Host, n.Role)
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Confirm unless --force
|
|
||||||
if !flags.Force {
|
|
||||||
fmt.Printf("This will DESTROY all data on these nodes. Anyone relay keys are preserved.\n")
|
|
||||||
fmt.Printf("Type 'yes' to confirm: ")
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
input, _ := reader.ReadString('\n')
|
|
||||||
if strings.TrimSpace(input) != "yes" {
|
|
||||||
fmt.Println("Aborted.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean each node
|
|
||||||
var failed []string
|
|
||||||
for i, node := range nodes {
|
|
||||||
fmt.Printf("[%d/%d] Cleaning %s...\n", i+1, len(nodes), node.Host)
|
|
||||||
if err := cleanNode(node, flags.Nuclear); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, " ✗ %s: %v\n", node.Host, err)
|
|
||||||
failed = append(failed, node.Host)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Printf(" ✓ %s cleaned\n\n", node.Host)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(failed) > 0 {
|
|
||||||
return fmt.Errorf("clean failed on %d node(s): %s", len(failed), strings.Join(failed, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("✓ Clean complete (%d nodes)\n", len(nodes))
|
|
||||||
fmt.Printf(" Anyone relay keys preserved at /var/lib/anon/\n")
|
|
||||||
fmt.Printf(" To reinstall: orama node install --vps-ip <ip> ...\n")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanNode(node inspector.Node, nuclear bool) error {
|
|
||||||
sudo := remotessh.SudoPrefix(node)
|
|
||||||
|
|
||||||
nuclearFlag := ""
|
|
||||||
if nuclear {
|
|
||||||
nuclearFlag = "NUCLEAR=1"
|
|
||||||
}
|
|
||||||
|
|
||||||
// The cleanup script runs on the remote node
|
|
||||||
script := fmt.Sprintf(`%sbash -c '
|
|
||||||
%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
|
|
||||||
systemctl stop "$svc" 2>/dev/null
|
|
||||||
systemctl disable "$svc" 2>/dev/null
|
|
||||||
done
|
|
||||||
|
|
||||||
# Kill stragglers
|
|
||||||
pkill -9 -f "orama-node" 2>/dev/null || true
|
|
||||||
pkill -9 -f "olric-server" 2>/dev/null || true
|
|
||||||
pkill -9 -f "ipfs" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Remove systemd units
|
|
||||||
rm -f /etc/systemd/system/orama-*.service
|
|
||||||
rm -f /etc/systemd/system/coredns.service
|
|
||||||
rm -f /etc/systemd/system/caddy.service
|
|
||||||
systemctl daemon-reload 2>/dev/null
|
|
||||||
|
|
||||||
# Tear down WireGuard
|
|
||||||
ip link delete wg0 2>/dev/null || true
|
|
||||||
rm -f /etc/wireguard/wg0.conf
|
|
||||||
|
|
||||||
# Reset firewall
|
|
||||||
ufw --force reset 2>/dev/null || true
|
|
||||||
ufw default deny incoming 2>/dev/null || true
|
|
||||||
ufw default allow outgoing 2>/dev/null || true
|
|
||||||
ufw allow 22/tcp 2>/dev/null || true
|
|
||||||
ufw --force enable 2>/dev/null || true
|
|
||||||
|
|
||||||
# Remove data
|
|
||||||
rm -rf /opt/orama
|
|
||||||
|
|
||||||
# Clean configs
|
|
||||||
rm -rf /etc/coredns
|
|
||||||
rm -rf /etc/caddy
|
|
||||||
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/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
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify Anyone keys preserved
|
|
||||||
if [ -d /var/lib/anon ]; then
|
|
||||||
echo " Anyone relay keys preserved at /var/lib/anon/"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " Node cleaned successfully"
|
|
||||||
'`, sudo, nuclearFlag)
|
|
||||||
|
|
||||||
return remotessh.RunSSHStreaming(node, script)
|
|
||||||
}
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
// Package enroll implements the OramaOS node enrollment command.
|
|
||||||
//
|
|
||||||
// Flow:
|
|
||||||
// 1. Operator fetches registration code from the OramaOS node (port 9999)
|
|
||||||
// 2. Operator provides code + invite token to Gateway
|
|
||||||
// 3. Gateway validates, generates cluster config, pushes to node
|
|
||||||
// 4. Node configures WireGuard, encrypts data partition, starts services
|
|
||||||
package enroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handle processes the enroll command.
|
|
||||||
func Handle(args []string) {
|
|
||||||
flags, err := ParseFlags(args)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Fetch registration code from the OramaOS node
|
|
||||||
fmt.Printf("Fetching registration code from %s:9999...\n", flags.NodeIP)
|
|
||||||
|
|
||||||
var code string
|
|
||||||
if flags.Code != "" {
|
|
||||||
// Code provided directly — skip fetch
|
|
||||||
code = flags.Code
|
|
||||||
} else {
|
|
||||||
fetchedCode, err := fetchRegistrationCode(flags.NodeIP)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: could not reach OramaOS node: %v\n", err)
|
|
||||||
fmt.Fprintf(os.Stderr, "Make sure the node is booted and port 9999 is reachable.\n")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
code = fetchedCode
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Registration code: %s\n", code)
|
|
||||||
|
|
||||||
// Step 2: Send enrollment request to the Gateway
|
|
||||||
fmt.Printf("Sending enrollment to Gateway at %s...\n", flags.GatewayURL)
|
|
||||||
|
|
||||||
if err := enrollWithGateway(flags.GatewayURL, flags.Token, code, flags.NodeIP); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: enrollment failed: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Node %s enrolled successfully.\n", flags.NodeIP)
|
|
||||||
fmt.Printf("The node is now configuring WireGuard and encrypting its data partition.\n")
|
|
||||||
fmt.Printf("This may take a few minutes. Check status with: orama node status --env %s\n", flags.Env)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchRegistrationCode retrieves the one-time registration code from the OramaOS node.
|
|
||||||
func fetchRegistrationCode(nodeIP string) (string, error) {
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
resp, err := client.Get(fmt.Sprintf("http://%s:9999/", nodeIP))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("GET failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusGone {
|
|
||||||
return "", fmt.Errorf("registration code already served (node may be partially enrolled)")
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("unexpected status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Expires string `json:"expires"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return "", fmt.Errorf("invalid response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.Code, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// enrollWithGateway sends the enrollment request to the Gateway, which validates
|
|
||||||
// the code and token, then pushes cluster configuration to the OramaOS node.
|
|
||||||
func enrollWithGateway(gatewayURL, token, code, nodeIP string) error {
|
|
||||||
body, _ := json.Marshal(map[string]string{
|
|
||||||
"code": code,
|
|
||||||
"token": token,
|
|
||||||
"node_ip": nodeIP,
|
|
||||||
})
|
|
||||||
|
|
||||||
req, err := http.NewRequest("POST", gatewayURL+"/v1/node/enroll", bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 60 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusUnauthorized {
|
|
||||||
return fmt.Errorf("invalid or expired invite token")
|
|
||||||
}
|
|
||||||
if resp.StatusCode == http.StatusBadRequest {
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("bad request: %s", string(respBody))
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
respBody, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("gateway returned %d: %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
package enroll
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Flags holds the parsed command-line flags for the enroll command.
|
|
||||||
type Flags struct {
|
|
||||||
NodeIP string // Public IP of the OramaOS node
|
|
||||||
Code string // Registration code (optional — fetched automatically if not provided)
|
|
||||||
Token string // Invite token for cluster joining
|
|
||||||
GatewayURL string // Gateway HTTPS URL
|
|
||||||
Env string // Environment name (for display only)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseFlags parses the enroll command flags.
|
|
||||||
func ParseFlags(args []string) (*Flags, error) {
|
|
||||||
fs := flag.NewFlagSet("enroll", flag.ContinueOnError)
|
|
||||||
fs.SetOutput(os.Stderr)
|
|
||||||
|
|
||||||
flags := &Flags{}
|
|
||||||
|
|
||||||
fs.StringVar(&flags.NodeIP, "node-ip", "", "Public IP of the OramaOS node (required)")
|
|
||||||
fs.StringVar(&flags.Code, "code", "", "Registration code from the node (auto-fetched if not provided)")
|
|
||||||
fs.StringVar(&flags.Token, "token", "", "Invite token for cluster joining (required)")
|
|
||||||
fs.StringVar(&flags.GatewayURL, "gateway", "", "Gateway URL (required, e.g. https://gateway.example.com)")
|
|
||||||
fs.StringVar(&flags.Env, "env", "production", "Environment name")
|
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if flags.NodeIP == "" {
|
|
||||||
return nil, fmt.Errorf("--node-ip is required")
|
|
||||||
}
|
|
||||||
if flags.Token == "" {
|
|
||||||
return nil, fmt.Errorf("--token is required")
|
|
||||||
}
|
|
||||||
if flags.GatewayURL == "" {
|
|
||||||
return nil, fmt.Errorf("--gateway is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
return flags, nil
|
|
||||||
}
|
|
||||||
@ -1,261 +0,0 @@
|
|||||||
package push
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Flags holds push command flags.
|
|
||||||
type Flags struct {
|
|
||||||
Env string // Target environment (devnet, testnet)
|
|
||||||
Node string // Single node IP (optional)
|
|
||||||
Direct bool // Sequential upload to each node (no fanout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle is the entry point for the push command.
|
|
||||||
func Handle(args []string) {
|
|
||||||
flags, err := parseFlags(args)
|
|
||||||
if err != nil {
|
|
||||||
if err == flag.ErrHelp {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := execute(flags); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFlags(args []string) (*Flags, error) {
|
|
||||||
fs := flag.NewFlagSet("push", flag.ContinueOnError)
|
|
||||||
fs.SetOutput(os.Stderr)
|
|
||||||
|
|
||||||
flags := &Flags{}
|
|
||||||
fs.StringVar(&flags.Env, "env", "", "Target environment (devnet, testnet) [required]")
|
|
||||||
fs.StringVar(&flags.Node, "node", "", "Push to a single node IP only")
|
|
||||||
fs.BoolVar(&flags.Direct, "direct", false, "Upload directly to each node (no hub fanout)")
|
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if flags.Env == "" {
|
|
||||||
return nil, fmt.Errorf("--env is required\nUsage: orama node push --env <devnet|testnet>")
|
|
||||||
}
|
|
||||||
|
|
||||||
return flags, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func execute(flags *Flags) error {
|
|
||||||
// Find archive
|
|
||||||
archivePath := findNewestArchive()
|
|
||||||
if archivePath == "" {
|
|
||||||
return fmt.Errorf("no binary archive found in /tmp/ (run `orama build` first)")
|
|
||||||
}
|
|
||||||
|
|
||||||
info, _ := os.Stat(archivePath)
|
|
||||||
fmt.Printf("Archive: %s (%s)\n", filepath.Base(archivePath), formatBytes(info.Size()))
|
|
||||||
|
|
||||||
// Resolve nodes
|
|
||||||
nodes, err := remotessh.LoadEnvNodes(flags.Env)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare wallet-derived SSH keys
|
|
||||||
cleanup, err := remotessh.PrepareNodeKeys(nodes)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
// Filter to single node if specified
|
|
||||||
if flags.Node != "" {
|
|
||||||
nodes = remotessh.FilterByIP(nodes, flags.Node)
|
|
||||||
if len(nodes) == 0 {
|
|
||||||
return fmt.Errorf("node %s not found in %s environment", flags.Node, flags.Env)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Environment: %s (%d nodes)\n\n", flags.Env, len(nodes))
|
|
||||||
|
|
||||||
if flags.Direct || len(nodes) == 1 {
|
|
||||||
return pushDirect(archivePath, nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load keys into ssh-agent for fanout forwarding
|
|
||||||
if err := remotessh.LoadAgentKeys(nodes); err != nil {
|
|
||||||
return fmt.Errorf("load agent keys for fanout: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return pushFanout(archivePath, nodes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// pushDirect uploads the archive to each node sequentially.
|
|
||||||
func pushDirect(archivePath string, nodes []inspector.Node) error {
|
|
||||||
remotePath := "/tmp/" + filepath.Base(archivePath)
|
|
||||||
|
|
||||||
for i, node := range nodes {
|
|
||||||
fmt.Printf("[%d/%d] Pushing to %s...\n", i+1, len(nodes), node.Host)
|
|
||||||
|
|
||||||
if err := remotessh.UploadFile(node, archivePath, remotePath); err != nil {
|
|
||||||
return fmt.Errorf("upload to %s failed: %w", node.Host, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := extractOnNode(node, remotePath); err != nil {
|
|
||||||
return fmt.Errorf("extract on %s failed: %w", node.Host, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" ✓ %s done\n\n", node.Host)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("✓ Push complete (%d nodes)\n", len(nodes))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pushFanout uploads to a hub node, then fans out to all others via agent forwarding.
|
|
||||||
func pushFanout(archivePath string, nodes []inspector.Node) error {
|
|
||||||
hub := remotessh.PickHubNode(nodes)
|
|
||||||
remotePath := "/tmp/" + filepath.Base(archivePath)
|
|
||||||
|
|
||||||
// Step 1: Upload to hub
|
|
||||||
fmt.Printf("[hub] Uploading to %s...\n", hub.Host)
|
|
||||||
if err := remotessh.UploadFile(hub, archivePath, remotePath); err != nil {
|
|
||||||
return fmt.Errorf("upload to hub %s failed: %w", hub.Host, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := extractOnNode(hub, remotePath); err != nil {
|
|
||||||
return fmt.Errorf("extract on hub %s failed: %w", hub.Host, err)
|
|
||||||
}
|
|
||||||
fmt.Printf(" ✓ hub %s done\n\n", hub.Host)
|
|
||||||
|
|
||||||
// Step 2: Fan out from hub to remaining nodes in parallel (via agent forwarding)
|
|
||||||
remaining := make([]inspector.Node, 0, len(nodes)-1)
|
|
||||||
for _, n := range nodes {
|
|
||||||
if n.Host != hub.Host {
|
|
||||||
remaining = append(remaining, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(remaining) == 0 {
|
|
||||||
fmt.Printf("✓ Push complete (1 node)\n")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("[fanout] Distributing from %s to %d nodes...\n", hub.Host, len(remaining))
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
errors := make([]error, len(remaining))
|
|
||||||
|
|
||||||
for i, target := range remaining {
|
|
||||||
wg.Add(1)
|
|
||||||
go func(idx int, target inspector.Node) {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
// SCP from hub to target (agent forwarding serves the key)
|
|
||||||
scpCmd := fmt.Sprintf("scp -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 %s %s@%s:%s",
|
|
||||||
remotePath, target.User, target.Host, remotePath)
|
|
||||||
|
|
||||||
if err := remotessh.RunSSHStreaming(hub, scpCmd, remotessh.WithAgentForward()); err != nil {
|
|
||||||
errors[idx] = fmt.Errorf("fanout to %s failed: %w", target.Host, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := extractOnNodeVia(hub, target, remotePath); err != nil {
|
|
||||||
errors[idx] = fmt.Errorf("extract on %s failed: %w", target.Host, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" ✓ %s done\n", target.Host)
|
|
||||||
}(i, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
// Check for errors
|
|
||||||
var failed []string
|
|
||||||
for i, err := range errors {
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, " ✗ %s: %v\n", remaining[i].Host, err)
|
|
||||||
failed = append(failed, remaining[i].Host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(failed) > 0 {
|
|
||||||
return fmt.Errorf("push failed on %d node(s): %s", len(failed), strings.Join(failed, ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\n✓ Push complete (%d nodes)\n", len(nodes))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractOnNode extracts the archive on a remote node.
|
|
||||||
func extractOnNode(node inspector.Node, remotePath string) error {
|
|
||||||
sudo := remotessh.SudoPrefix(node)
|
|
||||||
cmd := fmt.Sprintf("%smkdir -p /opt/orama && %star xzf %s -C /opt/orama && %srm -f %s",
|
|
||||||
sudo, sudo, remotePath, sudo, remotePath)
|
|
||||||
return remotessh.RunSSHStreaming(node, cmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractOnNodeVia extracts the archive on a target node by SSHing through the hub.
|
|
||||||
// Uses agent forwarding so the hub can authenticate to the target.
|
|
||||||
func extractOnNodeVia(hub, target inspector.Node, remotePath string) error {
|
|
||||||
sudo := remotessh.SudoPrefix(target)
|
|
||||||
extractCmd := fmt.Sprintf("%smkdir -p /opt/orama && %star xzf %s -C /opt/orama && %srm -f %s",
|
|
||||||
sudo, sudo, remotePath, sudo, remotePath)
|
|
||||||
|
|
||||||
// SSH from hub to target to extract (agent forwarding serves the key)
|
|
||||||
sshCmd := fmt.Sprintf("ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 %s@%s '%s'",
|
|
||||||
target.User, target.Host, extractCmd)
|
|
||||||
|
|
||||||
return remotessh.RunSSHStreaming(hub, sshCmd, remotessh.WithAgentForward())
|
|
||||||
}
|
|
||||||
|
|
||||||
// findNewestArchive finds the newest binary archive in /tmp/.
|
|
||||||
func findNewestArchive() string {
|
|
||||||
entries, err := os.ReadDir("/tmp")
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var best string
|
|
||||||
var bestMod int64
|
|
||||||
for _, entry := range entries {
|
|
||||||
name := entry.Name()
|
|
||||||
if strings.HasPrefix(name, "orama-") && strings.Contains(name, "-linux-") && strings.HasSuffix(name, ".tar.gz") {
|
|
||||||
info, err := entry.Info()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if info.ModTime().Unix() > bestMod {
|
|
||||||
best = filepath.Join("/tmp", name)
|
|
||||||
bestMod = info.ModTime().Unix()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatBytes(b int64) string {
|
|
||||||
const unit = 1024
|
|
||||||
if b < unit {
|
|
||||||
return fmt.Sprintf("%d B", b)
|
|
||||||
}
|
|
||||||
div, exp := int64(unit), 0
|
|
||||||
for n := b / unit; n >= unit; n /= unit {
|
|
||||||
div *= unit
|
|
||||||
exp++
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
|
||||||
}
|
|
||||||
@ -1,312 +0,0 @@
|
|||||||
package recover
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/cli/remotessh"
|
|
||||||
"github.com/DeBrosOfficial/network/pkg/inspector"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Flags holds recover-raft command flags.
|
|
||||||
type Flags struct {
|
|
||||||
Env string // Target environment
|
|
||||||
Leader string // Leader node IP (highest commit index)
|
|
||||||
Force bool // Skip confirmation
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
raftDir = "/opt/orama/.orama/data/rqlite/raft"
|
|
||||||
backupDir = "/tmp/rqlite-raft-backup"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handle is the entry point for the recover-raft command.
|
|
||||||
func Handle(args []string) {
|
|
||||||
flags, err := parseFlags(args)
|
|
||||||
if err != nil {
|
|
||||||
if err == flag.ErrHelp {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := execute(flags); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFlags(args []string) (*Flags, error) {
|
|
||||||
fs := flag.NewFlagSet("recover-raft", flag.ContinueOnError)
|
|
||||||
fs.SetOutput(os.Stderr)
|
|
||||||
|
|
||||||
flags := &Flags{}
|
|
||||||
fs.StringVar(&flags.Env, "env", "", "Target environment (devnet, testnet) [required]")
|
|
||||||
fs.StringVar(&flags.Leader, "leader", "", "Leader node IP (node with highest commit index) [required]")
|
|
||||||
fs.BoolVar(&flags.Force, "force", false, "Skip confirmation (DESTRUCTIVE)")
|
|
||||||
|
|
||||||
if err := fs.Parse(args); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if flags.Env == "" {
|
|
||||||
return nil, fmt.Errorf("--env is required\nUsage: orama node recover-raft --env <devnet|testnet> --leader <ip>")
|
|
||||||
}
|
|
||||||
if flags.Leader == "" {
|
|
||||||
return nil, fmt.Errorf("--leader is required\nUsage: orama node recover-raft --env <devnet|testnet> --leader <ip>")
|
|
||||||
}
|
|
||||||
|
|
||||||
return flags, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func execute(flags *Flags) error {
|
|
||||||
nodes, err := remotessh.LoadEnvNodes(flags.Env)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup, err := remotessh.PrepareNodeKeys(nodes)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
// Find leader node
|
|
||||||
leaderNodes := remotessh.FilterByIP(nodes, flags.Leader)
|
|
||||||
if len(leaderNodes) == 0 {
|
|
||||||
return fmt.Errorf("leader %s not found in %s environment", flags.Leader, flags.Env)
|
|
||||||
}
|
|
||||||
leader := leaderNodes[0]
|
|
||||||
|
|
||||||
// Separate leader from followers
|
|
||||||
var followers []inspector.Node
|
|
||||||
for _, n := range nodes {
|
|
||||||
if n.Host != leader.Host {
|
|
||||||
followers = append(followers, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print plan
|
|
||||||
fmt.Printf("Recover Raft: %s (%d nodes)\n", flags.Env, len(nodes))
|
|
||||||
fmt.Printf(" Leader candidate: %s (%s) — raft/ data preserved\n", leader.Host, leader.Role)
|
|
||||||
for _, n := range followers {
|
|
||||||
fmt.Printf(" - %s (%s) — raft/ will be deleted\n", n.Host, n.Role)
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Confirm unless --force
|
|
||||||
if !flags.Force {
|
|
||||||
fmt.Printf("⚠️ THIS WILL:\n")
|
|
||||||
fmt.Printf(" 1. Stop orama-node on ALL %d nodes\n", len(nodes))
|
|
||||||
fmt.Printf(" 2. DELETE raft/ data on %d nodes (backup to %s)\n", len(followers), backupDir)
|
|
||||||
fmt.Printf(" 3. Keep raft/ data ONLY on %s (leader candidate)\n", leader.Host)
|
|
||||||
fmt.Printf(" 4. Restart all nodes to reform the cluster\n")
|
|
||||||
fmt.Printf("\nType 'yes' to confirm: ")
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
|
||||||
input, _ := reader.ReadString('\n')
|
|
||||||
if strings.TrimSpace(input) != "yes" {
|
|
||||||
fmt.Println("Aborted.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 1: Stop orama-node on ALL nodes
|
|
||||||
if err := phase1StopAll(nodes); err != nil {
|
|
||||||
return fmt.Errorf("phase 1 (stop all): %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: Backup and delete raft/ on non-leader nodes
|
|
||||||
if err := phase2ClearFollowers(followers); err != nil {
|
|
||||||
return fmt.Errorf("phase 2 (clear followers): %w", err)
|
|
||||||
}
|
|
||||||
fmt.Printf(" Leader node %s raft/ data preserved.\n\n", leader.Host)
|
|
||||||
|
|
||||||
// Phase 3: Start leader node and wait for Leader state
|
|
||||||
if err := phase3StartLeader(leader); err != nil {
|
|
||||||
return fmt.Errorf("phase 3 (start leader): %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 4: Start remaining nodes in batches
|
|
||||||
if err := phase4StartFollowers(followers); err != nil {
|
|
||||||
return fmt.Errorf("phase 4 (start followers): %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 5: Verify cluster health
|
|
||||||
phase5Verify(nodes, leader)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func phase1StopAll(nodes []inspector.Node) error {
|
|
||||||
fmt.Printf("== Phase 1: Stopping orama-node on all %d nodes ==\n", len(nodes))
|
|
||||||
|
|
||||||
var failed []inspector.Node
|
|
||||||
for _, node := range nodes {
|
|
||||||
sudo := remotessh.SudoPrefix(node)
|
|
||||||
fmt.Printf(" Stopping %s ... ", node.Host)
|
|
||||||
|
|
||||||
cmd := fmt.Sprintf("%ssystemctl stop orama-node 2>&1 && echo STOPPED", sudo)
|
|
||||||
if err := remotessh.RunSSHStreaming(node, cmd); err != nil {
|
|
||||||
fmt.Printf("FAILED\n")
|
|
||||||
failed = append(failed, node)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kill stragglers
|
|
||||||
if len(failed) > 0 {
|
|
||||||
fmt.Printf("\n⚠️ %d nodes failed to stop. Attempting kill...\n", len(failed))
|
|
||||||
for _, node := range failed {
|
|
||||||
sudo := remotessh.SudoPrefix(node)
|
|
||||||
cmd := fmt.Sprintf("%skillall -9 orama-node rqlited 2>/dev/null; echo KILLED", sudo)
|
|
||||||
_ = remotessh.RunSSHStreaming(node, cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\nWaiting 5s for processes to fully stop...\n")
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func phase2ClearFollowers(followers []inspector.Node) error {
|
|
||||||
fmt.Printf("== Phase 2: Clearing raft state on %d non-leader nodes ==\n", len(followers))
|
|
||||||
|
|
||||||
for _, node := range followers {
|
|
||||||
sudo := remotessh.SudoPrefix(node)
|
|
||||||
fmt.Printf(" Clearing %s ... ", node.Host)
|
|
||||||
|
|
||||||
script := fmt.Sprintf(`%sbash -c '
|
|
||||||
rm -rf %s
|
|
||||||
if [ -d %s ]; then
|
|
||||||
cp -r %s %s 2>/dev/null || true
|
|
||||||
rm -rf %s
|
|
||||||
echo "CLEARED (backup at %s)"
|
|
||||||
else
|
|
||||||
echo "NO_RAFT_DIR (nothing to clear)"
|
|
||||||
fi
|
|
||||||
'`, sudo, backupDir, raftDir, raftDir, backupDir, raftDir, backupDir)
|
|
||||||
|
|
||||||
if err := remotessh.RunSSHStreaming(node, script); err != nil {
|
|
||||||
fmt.Printf("FAILED: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func phase3StartLeader(leader inspector.Node) error {
|
|
||||||
fmt.Printf("== Phase 3: Starting leader node (%s) ==\n", leader.Host)
|
|
||||||
|
|
||||||
sudo := remotessh.SudoPrefix(leader)
|
|
||||||
startCmd := fmt.Sprintf("%ssystemctl start orama-node", sudo)
|
|
||||||
if err := remotessh.RunSSHStreaming(leader, startCmd); err != nil {
|
|
||||||
return fmt.Errorf("failed to start leader node %s: %w", leader.Host, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" Waiting for leader to become Leader...\n")
|
|
||||||
maxWait := 120
|
|
||||||
elapsed := 0
|
|
||||||
|
|
||||||
for elapsed < maxWait {
|
|
||||||
// Check raft state via RQLite status endpoint
|
|
||||||
checkCmd := `curl -s --max-time 3 http://localhost:5001/status 2>/dev/null | python3 -c "
|
|
||||||
import sys,json
|
|
||||||
try:
|
|
||||||
d=json.load(sys.stdin)
|
|
||||||
print(d.get('store',{}).get('raft',{}).get('state',''))
|
|
||||||
except:
|
|
||||||
print('')
|
|
||||||
" 2>/dev/null || echo ""`
|
|
||||||
|
|
||||||
// We can't easily capture output from RunSSHStreaming, so we use a simple approach
|
|
||||||
// Check via a combined command that prints a marker
|
|
||||||
stateCheckCmd := fmt.Sprintf(`state=$(%s); echo "RAFT_STATE=$state"`, checkCmd)
|
|
||||||
// Since RunSSHStreaming prints to stdout, we'll poll and let user see the state
|
|
||||||
fmt.Printf(" ... polling (%ds / %ds)\n", elapsed, maxWait)
|
|
||||||
|
|
||||||
// Try to check state - the output goes to stdout via streaming
|
|
||||||
_ = remotessh.RunSSHStreaming(leader, stateCheckCmd)
|
|
||||||
|
|
||||||
time.Sleep(5 * time.Second)
|
|
||||||
elapsed += 5
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" Leader start complete. Check output above for state.\n\n")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func phase4StartFollowers(followers []inspector.Node) error {
|
|
||||||
fmt.Printf("== Phase 4: Starting %d remaining nodes ==\n", len(followers))
|
|
||||||
|
|
||||||
batchSize := 3
|
|
||||||
for i, node := range followers {
|
|
||||||
sudo := remotessh.SudoPrefix(node)
|
|
||||||
fmt.Printf(" Starting %s ... ", node.Host)
|
|
||||||
|
|
||||||
cmd := fmt.Sprintf("%ssystemctl start orama-node && echo STARTED", sudo)
|
|
||||||
if err := remotessh.RunSSHStreaming(node, cmd); err != nil {
|
|
||||||
fmt.Printf("FAILED: %v\n", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
// Batch delay for cluster stability
|
|
||||||
if (i+1)%batchSize == 0 && i+1 < len(followers) {
|
|
||||||
fmt.Printf(" (waiting 15s between batches for cluster stability)\n")
|
|
||||||
time.Sleep(15 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func phase5Verify(nodes []inspector.Node, leader inspector.Node) {
|
|
||||||
fmt.Printf("== Phase 5: Waiting for cluster to stabilize ==\n")
|
|
||||||
|
|
||||||
// Wait in 30s increments
|
|
||||||
for _, s := range []int{30, 60, 90, 120} {
|
|
||||||
time.Sleep(30 * time.Second)
|
|
||||||
fmt.Printf(" ... %ds\n", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\n== Cluster status ==\n")
|
|
||||||
for _, node := range nodes {
|
|
||||||
marker := ""
|
|
||||||
if node.Host == leader.Host {
|
|
||||||
marker = " ← LEADER"
|
|
||||||
}
|
|
||||||
|
|
||||||
checkCmd := `curl -s --max-time 5 http://localhost:5001/status 2>/dev/null | python3 -c "
|
|
||||||
import sys,json
|
|
||||||
try:
|
|
||||||
d=json.load(sys.stdin)
|
|
||||||
r=d.get('store',{}).get('raft',{})
|
|
||||||
n=d.get('store',{}).get('num_nodes','?')
|
|
||||||
print(f'state={r.get(\"state\",\"?\")} commit={r.get(\"commit_index\",\"?\")} leader={r.get(\"leader\",{}).get(\"node_id\",\"?\")} nodes={n}')
|
|
||||||
except:
|
|
||||||
print('NO_RESPONSE')
|
|
||||||
" 2>/dev/null || echo "SSH_FAILED"`
|
|
||||||
|
|
||||||
fmt.Printf(" %s%s: ", node.Host, marker)
|
|
||||||
_ = remotessh.RunSSHStreaming(node, checkCmd)
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("\n== Recovery complete ==\n\n")
|
|
||||||
fmt.Printf("Next steps:\n")
|
|
||||||
fmt.Printf(" 1. Run 'orama monitor report --env <env>' to verify full cluster health\n")
|
|
||||||
fmt.Printf(" 2. If some nodes show Candidate state, give them more time (up to 5 min)\n")
|
|
||||||
fmt.Printf(" 3. If nodes fail to join, check /opt/orama/.orama/logs/rqlite-node.log on the node\n")
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
package report
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// collectAnyone gathers Anyone Protocol relay/client health information.
|
|
||||||
func collectAnyone() *AnyoneReport {
|
|
||||||
r := &AnyoneReport{}
|
|
||||||
|
|
||||||
// 1. RelayActive: systemctl is-active orama-anyone-relay
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "systemctl", "is-active", "orama-anyone-relay"); err == nil {
|
|
||||||
r.RelayActive = strings.TrimSpace(out) == "active"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. ClientActive: systemctl is-active orama-anyone-client
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "systemctl", "is-active", "orama-anyone-client"); err == nil {
|
|
||||||
r.ClientActive = strings.TrimSpace(out) == "active"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Mode: derive from active state
|
|
||||||
if r.RelayActive {
|
|
||||||
r.Mode = "relay"
|
|
||||||
} else if r.ClientActive {
|
|
||||||
r.Mode = "client"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. ORPortListening, SocksListening, ControlListening: check ports in ss -tlnp
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "ss", "-tlnp"); err == nil {
|
|
||||||
r.ORPortListening = portIsListening(out, 9001)
|
|
||||||
r.SocksListening = portIsListening(out, 9050)
|
|
||||||
r.ControlListening = portIsListening(out, 9051)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Bootstrapped / BootstrapPct: parse last "Bootstrapped" line from notices.log
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "bash", "-c",
|
|
||||||
`grep "Bootstrapped" /var/log/anon/notices.log 2>/dev/null | tail -1`); err == nil {
|
|
||||||
out = strings.TrimSpace(out)
|
|
||||||
if out != "" {
|
|
||||||
// Parse percentage from lines like:
|
|
||||||
// "... Bootstrapped 100% (done): Done"
|
|
||||||
// "... Bootstrapped 85%: Loading relay descriptors"
|
|
||||||
re := regexp.MustCompile(`Bootstrapped\s+(\d+)%`)
|
|
||||||
if m := re.FindStringSubmatch(out); len(m) >= 2 {
|
|
||||||
if pct, err := strconv.Atoi(m[1]); err == nil {
|
|
||||||
r.BootstrapPct = pct
|
|
||||||
r.Bootstrapped = pct == 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Fingerprint: read /var/lib/anon/fingerprint
|
|
||||||
if data, err := os.ReadFile("/var/lib/anon/fingerprint"); err == nil {
|
|
||||||
line := strings.TrimSpace(string(data))
|
|
||||||
// The file may contain "nickname fingerprint" — extract just the fingerprint.
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) >= 2 {
|
|
||||||
r.Fingerprint = fields[1]
|
|
||||||
} else if len(fields) == 1 {
|
|
||||||
r.Fingerprint = fields[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. Nickname: extract from anonrc config
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "bash", "-c",
|
|
||||||
`grep "^Nickname" /etc/anon/anonrc 2>/dev/null | awk '{print $2}'`); err == nil {
|
|
||||||
r.Nickname = strings.TrimSpace(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
package report
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// collectDeployments discovers deployed applications by querying the local gateway.
|
|
||||||
func collectDeployments() *DeploymentsReport {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
report := &DeploymentsReport{}
|
|
||||||
|
|
||||||
// Query the local gateway for deployment list
|
|
||||||
url := "http://localhost:8080/v1/health"
|
|
||||||
body, err := httpGet(ctx, url)
|
|
||||||
if err != nil {
|
|
||||||
// Gateway not available — fall back to systemd unit discovery
|
|
||||||
return collectDeploymentsFromSystemd()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if gateway reports deployment counts in health response
|
|
||||||
var health map[string]interface{}
|
|
||||||
if err := json.Unmarshal(body, &health); err == nil {
|
|
||||||
if deps, ok := health["deployments"].(map[string]interface{}); ok {
|
|
||||||
if v, ok := deps["total"].(float64); ok {
|
|
||||||
report.TotalCount = int(v)
|
|
||||||
}
|
|
||||||
if v, ok := deps["running"].(float64); ok {
|
|
||||||
report.RunningCount = int(v)
|
|
||||||
}
|
|
||||||
if v, ok := deps["failed"].(float64); ok {
|
|
||||||
report.FailedCount = int(v)
|
|
||||||
}
|
|
||||||
return report
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: count deployment systemd units
|
|
||||||
return collectDeploymentsFromSystemd()
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectDeploymentsFromSystemd discovers deployments by listing systemd units.
|
|
||||||
func collectDeploymentsFromSystemd() *DeploymentsReport {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
report := &DeploymentsReport{}
|
|
||||||
|
|
||||||
// List orama-deploy-* units
|
|
||||||
out, err := runCmd(ctx, "systemctl", "list-units", "--type=service", "--no-legend", "--no-pager", "orama-deploy-*")
|
|
||||||
if err != nil {
|
|
||||||
return report
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range strings.Split(out, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
report.TotalCount++
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
// systemctl list-units format: UNIT LOAD ACTIVE SUB DESCRIPTION...
|
|
||||||
if len(fields) >= 4 {
|
|
||||||
switch fields[3] {
|
|
||||||
case "running":
|
|
||||||
report.RunningCount++
|
|
||||||
case "failed", "dead":
|
|
||||||
report.FailedCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return report
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectServerless checks if the serverless engine is available via the gateway health endpoint.
|
|
||||||
func collectServerless() *ServerlessReport {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
report := &ServerlessReport{
|
|
||||||
EngineStatus: "unknown",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check gateway health for serverless subsystem
|
|
||||||
url := "http://localhost:8080/v1/health"
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return report
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
report.EngineStatus = "unreachable"
|
|
||||||
return report
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode == http.StatusOK {
|
|
||||||
report.EngineStatus = "healthy"
|
|
||||||
} else {
|
|
||||||
report.EngineStatus = fmt.Sprintf("unhealthy (HTTP %d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
return report
|
|
||||||
}
|
|
||||||
@ -1,254 +0,0 @@
|
|||||||
package report
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// collectDNS gathers CoreDNS, Caddy, and DNS resolution health information.
|
|
||||||
// Only called when /etc/coredns exists.
|
|
||||||
func collectDNS() *DNSReport {
|
|
||||||
r := &DNSReport{}
|
|
||||||
|
|
||||||
// Set TLS days to -1 by default (failure state).
|
|
||||||
r.BaseTLSDaysLeft = -1
|
|
||||||
r.WildTLSDaysLeft = -1
|
|
||||||
|
|
||||||
// 1. CoreDNSActive: systemctl is-active coredns
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "systemctl", "is-active", "coredns"); err == nil {
|
|
||||||
r.CoreDNSActive = strings.TrimSpace(out) == "active"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. CaddyActive: systemctl is-active caddy
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "systemctl", "is-active", "caddy"); err == nil {
|
|
||||||
r.CaddyActive = strings.TrimSpace(out) == "active"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Port53Bound: check :53 in ss -ulnp
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "ss", "-ulnp"); err == nil {
|
|
||||||
r.Port53Bound = strings.Contains(out, ":53 ") || strings.Contains(out, ":53\t")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Port80Bound and Port443Bound: check in ss -tlnp
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "ss", "-tlnp"); err == nil {
|
|
||||||
r.Port80Bound = strings.Contains(out, ":80 ") || strings.Contains(out, ":80\t")
|
|
||||||
r.Port443Bound = strings.Contains(out, ":443 ") || strings.Contains(out, ":443\t")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. CoreDNSMemMB: ps -C coredns -o rss=
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "ps", "-C", "coredns", "-o", "rss=", "--no-headers"); err == nil {
|
|
||||||
line := strings.TrimSpace(out)
|
|
||||||
if line != "" {
|
|
||||||
first := strings.Fields(line)[0]
|
|
||||||
if kb, err := strconv.Atoi(first); err == nil {
|
|
||||||
r.CoreDNSMemMB = kb / 1024
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. CoreDNSRestarts: systemctl show coredns --property=NRestarts
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "systemctl", "show", "coredns", "--property=NRestarts"); err == nil {
|
|
||||||
props := parseProperties(out)
|
|
||||||
r.CoreDNSRestarts = parseInt(props["NRestarts"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. LogErrors: grep errors from coredns journal (last 5 min)
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "bash", "-c",
|
|
||||||
`journalctl -u coredns --no-pager -n 100 --since "5 min ago" 2>/dev/null | grep -ciE "(error|ERR)" || echo 0`); err == nil {
|
|
||||||
if n, err := strconv.Atoi(strings.TrimSpace(out)); err == nil {
|
|
||||||
r.LogErrors = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. CorefileExists: check /etc/coredns/Corefile
|
|
||||||
if _, err := os.Stat("/etc/coredns/Corefile"); err == nil {
|
|
||||||
r.CorefileExists = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse domain from Corefile for DNS resolution tests.
|
|
||||||
domain := parseDomain()
|
|
||||||
if domain == "" {
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. SOAResolves: dig SOA
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "dig", "@127.0.0.1", "SOA", domain, "+short", "+time=2"); err == nil {
|
|
||||||
r.SOAResolves = strings.TrimSpace(out) != ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 10. NSResolves and NSRecordCount: dig NS
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "dig", "@127.0.0.1", "NS", domain, "+short", "+time=2"); err == nil {
|
|
||||||
out = strings.TrimSpace(out)
|
|
||||||
if out != "" {
|
|
||||||
r.NSResolves = true
|
|
||||||
lines := strings.Split(out, "\n")
|
|
||||||
count := 0
|
|
||||||
for _, l := range lines {
|
|
||||||
if strings.TrimSpace(l) != "" {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.NSRecordCount = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 11. WildcardResolves: dig A test.<domain>
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "dig", "@127.0.0.1", "A", "test."+domain, "+short", "+time=2"); err == nil {
|
|
||||||
r.WildcardResolves = strings.TrimSpace(out) != ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 12. BaseAResolves: dig A <domain>
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "dig", "@127.0.0.1", "A", domain, "+short", "+time=2"); err == nil {
|
|
||||||
r.BaseAResolves = strings.TrimSpace(out) != ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 13. BaseTLSDaysLeft: check TLS cert expiry for base domain
|
|
||||||
r.BaseTLSDaysLeft = checkTLSDaysLeft(domain, domain)
|
|
||||||
|
|
||||||
// 14. WildTLSDaysLeft: check TLS cert expiry for wildcard
|
|
||||||
r.WildTLSDaysLeft = checkTLSDaysLeft("*."+domain, domain)
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDomain reads /etc/coredns/Corefile and extracts the base domain.
|
|
||||||
// It looks for zone block declarations like "example.com {" or "*.example.com {"
|
|
||||||
// and returns the base domain (without wildcard prefix).
|
|
||||||
func parseDomain() string {
|
|
||||||
data, err := os.ReadFile("/etc/coredns/Corefile")
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
content := string(data)
|
|
||||||
|
|
||||||
// Look for domain patterns in the Corefile.
|
|
||||||
// Common patterns:
|
|
||||||
// example.com {
|
|
||||||
// *.example.com {
|
|
||||||
// example.com:53 {
|
|
||||||
// We want to find a real domain, not "." (root zone).
|
|
||||||
domainRe := regexp.MustCompile(`(?m)^\s*\*?\.?([a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z0-9][-a-zA-Z0-9.]*[a-zA-Z])(?::\d+)?\s*\{`)
|
|
||||||
matches := domainRe.FindStringSubmatch(content)
|
|
||||||
if len(matches) >= 2 {
|
|
||||||
return matches[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: look for any line that looks like a domain block declaration.
|
|
||||||
for _, line := range strings.Split(content, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip trailing "{" and port suffix.
|
|
||||||
line = strings.TrimSuffix(line, "{")
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
|
|
||||||
// Remove port if present.
|
|
||||||
if idx := strings.LastIndex(line, ":"); idx > 0 {
|
|
||||||
if _, err := strconv.Atoi(line[idx+1:]); err == nil {
|
|
||||||
line = line[:idx]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip wildcard prefix.
|
|
||||||
line = strings.TrimPrefix(line, "*.")
|
|
||||||
|
|
||||||
// Check if it looks like a domain (has at least one dot and no spaces).
|
|
||||||
if strings.Contains(line, ".") && !strings.Contains(line, " ") && line != "." {
|
|
||||||
return strings.TrimSpace(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkTLSDaysLeft uses openssl to check the TLS certificate expiry date
|
|
||||||
// for a given servername connecting to localhost:443.
|
|
||||||
// Returns days until expiry, or -1 on any failure.
|
|
||||||
func checkTLSDaysLeft(servername, domain string) int {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
cmd := `echo | openssl s_client -servername ` + servername + ` -connect localhost:443 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null`
|
|
||||||
out, err := runCmd(ctx, "bash", "-c", cmd)
|
|
||||||
if err != nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output looks like: "notAfter=Mar 15 12:00:00 2025 GMT"
|
|
||||||
out = strings.TrimSpace(out)
|
|
||||||
if !strings.HasPrefix(out, "notAfter=") {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
dateStr := strings.TrimPrefix(out, "notAfter=")
|
|
||||||
dateStr = strings.TrimSpace(dateStr)
|
|
||||||
|
|
||||||
// Parse the date. OpenSSL uses the format: "Jan 2 15:04:05 2006 GMT"
|
|
||||||
layouts := []string{
|
|
||||||
"Jan 2 15:04:05 2006 GMT",
|
|
||||||
"Jan 2 15:04:05 2006 GMT",
|
|
||||||
"Jan 02 15:04:05 2006 GMT",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, layout := range layouts {
|
|
||||||
t, err := time.Parse(layout, dateStr)
|
|
||||||
if err == nil {
|
|
||||||
days := int(math.Floor(time.Until(t).Hours() / 24))
|
|
||||||
return days
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
package report
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// collectGateway checks the main gateway health endpoint and parses subsystem status.
|
|
||||||
func collectGateway() *GatewayReport {
|
|
||||||
r := &GatewayReport{}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost:6001/v1/health", nil)
|
|
||||||
if err != nil {
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
r.Responsive = false
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
r.Responsive = true
|
|
||||||
r.HTTPStatus = resp.StatusCode
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to parse the health response JSON.
|
|
||||||
// Expected: {"status":"ok","version":"...","subsystems":{"rqlite":{"status":"ok","latency":"2ms"},...}}
|
|
||||||
var health struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
Subsystems map[string]json.RawMessage `json:"subsystems"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &health); err != nil {
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
r.Version = health.Version
|
|
||||||
|
|
||||||
if len(health.Subsystems) > 0 {
|
|
||||||
r.Subsystems = make(map[string]SubsystemHealth, len(health.Subsystems))
|
|
||||||
for name, raw := range health.Subsystems {
|
|
||||||
var sub SubsystemHealth
|
|
||||||
if err := json.Unmarshal(raw, &sub); err == nil {
|
|
||||||
r.Subsystems[name] = sub
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
@ -1,166 +0,0 @@
|
|||||||
package report
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// collectIPFS gathers IPFS daemon and cluster health information.
|
|
||||||
func collectIPFS() *IPFSReport {
|
|
||||||
r := &IPFSReport{}
|
|
||||||
|
|
||||||
// 1. DaemonActive: systemctl is-active orama-ipfs
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "systemctl", "is-active", "orama-ipfs"); err == nil {
|
|
||||||
r.DaemonActive = strings.TrimSpace(out) == "active"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. ClusterActive: systemctl is-active orama-ipfs-cluster
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "systemctl", "is-active", "orama-ipfs-cluster"); err == nil {
|
|
||||||
r.ClusterActive = strings.TrimSpace(out) == "active"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. SwarmPeerCount: POST /api/v0/swarm/peers
|
|
||||||
{
|
|
||||||
body, err := ipfsPost("http://localhost:4501/api/v0/swarm/peers")
|
|
||||||
if err == nil {
|
|
||||||
var resp struct {
|
|
||||||
Peers []interface{} `json:"Peers"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &resp); err == nil {
|
|
||||||
r.SwarmPeerCount = len(resp.Peers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. ClusterPeerCount: GET /peers (with fallback to /id)
|
|
||||||
// The /peers endpoint does a synchronous round-trip to ALL cluster peers,
|
|
||||||
// so it can be slow if some peers are unreachable (ghost WG entries, etc.).
|
|
||||||
// Use a generous timeout and fall back to /id if /peers times out.
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if body, err := httpGet(ctx, "http://localhost:9094/peers"); err == nil {
|
|
||||||
var peers []interface{}
|
|
||||||
if err := json.Unmarshal(body, &peers); err == nil {
|
|
||||||
r.ClusterPeerCount = len(peers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: if /peers returned 0 (timeout or error), try /id which returns
|
|
||||||
// cached cluster_peers instantly without contacting other nodes.
|
|
||||||
if r.ClusterPeerCount == 0 {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if body, err := httpGet(ctx, "http://localhost:9094/id"); err == nil {
|
|
||||||
var resp struct {
|
|
||||||
ClusterPeers []string `json:"cluster_peers"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &resp); err == nil && len(resp.ClusterPeers) > 0 {
|
|
||||||
// cluster_peers includes self, so count is len(cluster_peers)
|
|
||||||
r.ClusterPeerCount = len(resp.ClusterPeers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. RepoSizeBytes/RepoMaxBytes: POST /api/v0/repo/stat
|
|
||||||
{
|
|
||||||
body, err := ipfsPost("http://localhost:4501/api/v0/repo/stat")
|
|
||||||
if err == nil {
|
|
||||||
var resp struct {
|
|
||||||
RepoSize int64 `json:"RepoSize"`
|
|
||||||
StorageMax int64 `json:"StorageMax"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &resp); err == nil {
|
|
||||||
r.RepoSizeBytes = resp.RepoSize
|
|
||||||
r.RepoMaxBytes = resp.StorageMax
|
|
||||||
if resp.StorageMax > 0 && resp.RepoSize > 0 {
|
|
||||||
r.RepoUsePct = int(float64(resp.RepoSize) / float64(resp.StorageMax) * 100)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. KuboVersion: POST /api/v0/version
|
|
||||||
{
|
|
||||||
body, err := ipfsPost("http://localhost:4501/api/v0/version")
|
|
||||||
if err == nil {
|
|
||||||
var resp struct {
|
|
||||||
Version string `json:"Version"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &resp); err == nil {
|
|
||||||
r.KuboVersion = resp.Version
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. ClusterVersion: GET /id
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if body, err := httpGet(ctx, "http://localhost:9094/id"); err == nil {
|
|
||||||
var resp struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &resp); err == nil {
|
|
||||||
r.ClusterVersion = resp.Version
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. HasSwarmKey: check file existence
|
|
||||||
if _, err := os.Stat("/opt/orama/.orama/data/ipfs/repo/swarm.key"); err == nil {
|
|
||||||
r.HasSwarmKey = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. BootstrapEmpty: POST /api/v0/bootstrap/list
|
|
||||||
{
|
|
||||||
body, err := ipfsPost("http://localhost:4501/api/v0/bootstrap/list")
|
|
||||||
if err == nil {
|
|
||||||
var resp struct {
|
|
||||||
Peers []interface{} `json:"Peers"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &resp); err == nil {
|
|
||||||
r.BootstrapEmpty = resp.Peers == nil || len(resp.Peers) == 0
|
|
||||||
} else {
|
|
||||||
// If we got a response but Peers is missing, treat as empty.
|
|
||||||
r.BootstrapEmpty = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// ipfsPost sends a POST request with an empty body to an IPFS API endpoint.
|
|
||||||
// IPFS uses POST for all API calls. Uses a 3-second timeout.
|
|
||||||
func ipfsPost(url string) ([]byte, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(nil))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
return io.ReadAll(resp.Body)
|
|
||||||
}
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
package report
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// collectNamespaces discovers deployed namespaces and checks health of their
|
|
||||||
// per-namespace services (RQLite, Olric, Gateway).
|
|
||||||
func collectNamespaces() []NamespaceReport {
|
|
||||||
namespaces := discoverNamespaces()
|
|
||||||
if len(namespaces) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var reports []NamespaceReport
|
|
||||||
for _, ns := range namespaces {
|
|
||||||
reports = append(reports, collectNamespaceReport(ns))
|
|
||||||
}
|
|
||||||
return reports
|
|
||||||
}
|
|
||||||
|
|
||||||
type nsInfo struct {
|
|
||||||
name string
|
|
||||||
portBase int
|
|
||||||
}
|
|
||||||
|
|
||||||
// discoverNamespaces finds deployed namespaces by looking for systemd service units
|
|
||||||
// and/or the filesystem namespace directory.
|
|
||||||
func discoverNamespaces() []nsInfo {
|
|
||||||
var result []nsInfo
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
|
|
||||||
// Strategy 1: Glob for orama-namespace-rqlite@*.service files.
|
|
||||||
matches, _ := filepath.Glob("/etc/systemd/system/orama-namespace-rqlite@*.service")
|
|
||||||
for _, path := range matches {
|
|
||||||
base := filepath.Base(path)
|
|
||||||
// Extract namespace name: orama-namespace-rqlite@<name>.service
|
|
||||||
name := strings.TrimPrefix(base, "orama-namespace-rqlite@")
|
|
||||||
name = strings.TrimSuffix(name, ".service")
|
|
||||||
if name == "" || seen[name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[name] = true
|
|
||||||
|
|
||||||
portBase := parsePortFromEnvFile(name)
|
|
||||||
if portBase > 0 {
|
|
||||||
result = append(result, nsInfo{name: name, portBase: portBase})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 2: Check filesystem for any namespaces not found via systemd.
|
|
||||||
nsDir := "/opt/orama/.orama/data/namespaces"
|
|
||||||
entries, err := os.ReadDir(nsDir)
|
|
||||||
if err == nil {
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() || seen[entry.Name()] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := entry.Name()
|
|
||||||
seen[name] = true
|
|
||||||
|
|
||||||
portBase := parsePortFromEnvFile(name)
|
|
||||||
if portBase > 0 {
|
|
||||||
result = append(result, nsInfo{name: name, portBase: portBase})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// parsePortFromEnvFile reads the RQLite env file for a namespace and extracts
|
|
||||||
// the HTTP port from HTTP_ADDR (e.g. "0.0.0.0:14001").
|
|
||||||
func parsePortFromEnvFile(namespace string) int {
|
|
||||||
envPath := fmt.Sprintf("/opt/orama/.orama/data/namespaces/%s/rqlite.env", namespace)
|
|
||||||
data, err := os.ReadFile(envPath)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
httpAddrRe := regexp.MustCompile(`HTTP_ADDR=\S+:(\d+)`)
|
|
||||||
if m := httpAddrRe.FindStringSubmatch(string(data)); len(m) >= 2 {
|
|
||||||
if port, err := strconv.Atoi(m[1]); err == nil {
|
|
||||||
return port
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectNamespaceReport checks the health of services for a single namespace.
|
|
||||||
func collectNamespaceReport(ns nsInfo) NamespaceReport {
|
|
||||||
r := NamespaceReport{
|
|
||||||
Name: ns.name,
|
|
||||||
PortBase: ns.portBase,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. RQLiteUp + RQLiteState: GET http://localhost:<port_base>/status
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
url := fmt.Sprintf("http://localhost:%d/status", ns.portBase)
|
|
||||||
if body, err := httpGet(ctx, url); err == nil {
|
|
||||||
r.RQLiteUp = true
|
|
||||||
|
|
||||||
var status map[string]interface{}
|
|
||||||
if err := json.Unmarshal(body, &status); err == nil {
|
|
||||||
r.RQLiteState = getNestedString(status, "store", "raft", "state")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. RQLiteReady: GET http://localhost:<port_base>/readyz
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
url := fmt.Sprintf("http://localhost:%d/readyz", ns.portBase)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
||||||
if err == nil {
|
|
||||||
if resp, err := http.DefaultClient.Do(req); err == nil {
|
|
||||||
io.Copy(io.Discard, resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
r.RQLiteReady = resp.StatusCode == http.StatusOK
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. OlricUp: check if port_base+2 is listening
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "ss", "-tlnp"); err == nil {
|
|
||||||
r.OlricUp = portIsListening(out, ns.portBase+2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. GatewayUp + GatewayStatus: GET http://localhost:<port_base+4>/v1/health
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
url := fmt.Sprintf("http://localhost:%d/v1/health", ns.portBase+4)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
||||||
if err == nil {
|
|
||||||
if resp, err := http.DefaultClient.Do(req); err == nil {
|
|
||||||
io.Copy(io.Discard, resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
r.GatewayUp = true
|
|
||||||
r.GatewayStatus = resp.StatusCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. SFUUp: check if namespace SFU systemd service is active (optional)
|
|
||||||
r.SFUUp = isNamespaceServiceActive("sfu", ns.name)
|
|
||||||
|
|
||||||
// 6. TURNUp: check if namespace TURN systemd service is active (optional)
|
|
||||||
r.TURNUp = isNamespaceServiceActive("turn", ns.name)
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// isNamespaceServiceActive checks if a namespace service is provisioned and active.
|
|
||||||
// Returns false if the service is not provisioned (no env file) or not running.
|
|
||||||
func isNamespaceServiceActive(serviceType, namespace string) bool {
|
|
||||||
// Only check if the service was provisioned (env file exists)
|
|
||||||
envFile := fmt.Sprintf("/opt/orama/.orama/data/namespaces/%s/%s.env", namespace, serviceType)
|
|
||||||
if _, err := os.Stat(envFile); err != nil {
|
|
||||||
return false // not provisioned
|
|
||||||
}
|
|
||||||
|
|
||||||
svcName := fmt.Sprintf("orama-namespace-%s@%s", serviceType, namespace)
|
|
||||||
cmd := exec.Command("systemctl", "is-active", "--quiet", svcName)
|
|
||||||
return cmd.Run() == nil
|
|
||||||
}
|
|
||||||
@ -1,253 +0,0 @@
|
|||||||
package report
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// collectNetwork gathers network connectivity, TCP stats, listening ports,
|
|
||||||
// and firewall status.
|
|
||||||
func collectNetwork() *NetworkReport {
|
|
||||||
r := &NetworkReport{}
|
|
||||||
|
|
||||||
// 1. InternetReachable: ping 8.8.8.8
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if _, err := runCmd(ctx, "ping", "-c", "1", "-W", "2", "8.8.8.8"); err == nil {
|
|
||||||
r.InternetReachable = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. DefaultRoute: ip route show default
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "ip", "route", "show", "default"); err == nil {
|
|
||||||
r.DefaultRoute = strings.TrimSpace(out) != ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. WGRouteExists: ip route show dev wg0
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "ip", "route", "show", "dev", "wg0"); err == nil {
|
|
||||||
r.WGRouteExists = strings.TrimSpace(out) != ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. TCPEstablished / TCPTimeWait: parse `ss -s`
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "ss", "-s"); err == nil {
|
|
||||||
for _, line := range strings.Split(out, "\n") {
|
|
||||||
lower := strings.ToLower(line)
|
|
||||||
if strings.HasPrefix(lower, "tcp:") || strings.Contains(lower, "estab") {
|
|
||||||
// Parse "estab N" and "timewait N" patterns from the line.
|
|
||||||
r.TCPEstablished = extractSSCount(line, "estab")
|
|
||||||
r.TCPTimeWait = extractSSCount(line, "timewait")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. TCPRetransRate: read /proc/net/snmp
|
|
||||||
{
|
|
||||||
if data, err := os.ReadFile("/proc/net/snmp"); err == nil {
|
|
||||||
r.TCPRetransRate = parseTCPRetransRate(string(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. ListeningPorts: ss -tlnp (TCP) + ss -ulnp (UDP)
|
|
||||||
{
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
|
|
||||||
ctx1, cancel1 := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel1()
|
|
||||||
if out, err := runCmd(ctx1, "ss", "-tlnp"); err == nil {
|
|
||||||
for _, pi := range parseSSListening(out, "tcp") {
|
|
||||||
key := strconv.Itoa(pi.Port) + "/" + pi.Proto
|
|
||||||
if !seen[key] {
|
|
||||||
seen[key] = true
|
|
||||||
r.ListeningPorts = append(r.ListeningPorts, pi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel2()
|
|
||||||
if out, err := runCmd(ctx2, "ss", "-ulnp"); err == nil {
|
|
||||||
for _, pi := range parseSSListening(out, "udp") {
|
|
||||||
key := strconv.Itoa(pi.Port) + "/" + pi.Proto
|
|
||||||
if !seen[key] {
|
|
||||||
seen[key] = true
|
|
||||||
r.ListeningPorts = append(r.ListeningPorts, pi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by port number for consistent output.
|
|
||||||
sort.Slice(r.ListeningPorts, func(i, j int) bool {
|
|
||||||
if r.ListeningPorts[i].Port != r.ListeningPorts[j].Port {
|
|
||||||
return r.ListeningPorts[i].Port < r.ListeningPorts[j].Port
|
|
||||||
}
|
|
||||||
return r.ListeningPorts[i].Proto < r.ListeningPorts[j].Proto
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. UFWActive: ufw status
|
|
||||||
{
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "ufw", "status"); err == nil {
|
|
||||||
r.UFWActive = strings.Contains(out, "Status: active")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. UFWRules: ufw status numbered
|
|
||||||
if r.UFWActive {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if out, err := runCmd(ctx, "ufw", "status", "numbered"); err == nil {
|
|
||||||
r.UFWRules = parseUFWRules(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractSSCount finds a pattern like "estab 42" or "timewait 7" in an ss -s line.
|
|
||||||
func extractSSCount(line, keyword string) int {
|
|
||||||
re := regexp.MustCompile(keyword + `\s+(\d+)`)
|
|
||||||
m := re.FindStringSubmatch(line)
|
|
||||||
if len(m) >= 2 {
|
|
||||||
if n, err := strconv.Atoi(m[1]); err == nil {
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTCPRetransRate parses /proc/net/snmp content to compute
|
|
||||||
// RetransSegs / OutSegs * 100.
|
|
||||||
//
|
|
||||||
// The file has paired lines: a header line followed by a values line.
|
|
||||||
// We look for the "Tcp:" header and extract RetransSegs and OutSegs.
|
|
||||||
func parseTCPRetransRate(data string) float64 {
|
|
||||||
lines := strings.Split(data, "\n")
|
|
||||||
for i := 0; i+1 < len(lines); i++ {
|
|
||||||
if !strings.HasPrefix(lines[i], "Tcp:") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
header := strings.Fields(lines[i])
|
|
||||||
values := strings.Fields(lines[i+1])
|
|
||||||
if !strings.HasPrefix(lines[i+1], "Tcp:") || len(header) != len(values) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var outSegs, retransSegs float64
|
|
||||||
for j, h := range header {
|
|
||||||
switch h {
|
|
||||||
case "OutSegs":
|
|
||||||
if v, err := strconv.ParseFloat(values[j], 64); err == nil {
|
|
||||||
outSegs = v
|
|
||||||
}
|
|
||||||
case "RetransSegs":
|
|
||||||
if v, err := strconv.ParseFloat(values[j], 64); err == nil {
|
|
||||||
retransSegs = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if outSegs > 0 {
|
|
||||||
return retransSegs / outSegs * 100
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseSSListening parses the output of `ss -tlnp` or `ss -ulnp` to extract
|
|
||||||
// port numbers and process names.
|
|
||||||
func parseSSListening(output, proto string) []PortInfo {
|
|
||||||
var ports []PortInfo
|
|
||||||
processRe := regexp.MustCompile(`users:\(\("([^"]+)"`)
|
|
||||||
|
|
||||||
for _, line := range strings.Split(output, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
// Skip header and empty lines.
|
|
||||||
if line == "" || strings.HasPrefix(line, "State") || strings.HasPrefix(line, "Netid") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 4 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// The local address:port is typically the 4th field (index 3) for ss -tlnp
|
|
||||||
// or the 5th field (index 4) for some formats. We look for a field with ":PORT".
|
|
||||||
localAddr := ""
|
|
||||||
for _, f := range fields {
|
|
||||||
if strings.Contains(f, ":") && !strings.HasPrefix(f, "users:") {
|
|
||||||
// Could be *:port, 0.0.0.0:port, [::]:port, 127.0.0.1:port, etc.
|
|
||||||
if idx := strings.LastIndex(f, ":"); idx >= 0 {
|
|
||||||
portStr := f[idx+1:]
|
|
||||||
if _, err := strconv.Atoi(portStr); err == nil {
|
|
||||||
localAddr = f
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if localAddr == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
idx := strings.LastIndex(localAddr, ":")
|
|
||||||
if idx < 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
portStr := localAddr[idx+1:]
|
|
||||||
port, err := strconv.Atoi(portStr)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
process := ""
|
|
||||||
if m := processRe.FindStringSubmatch(line); len(m) >= 2 {
|
|
||||||
process = m[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
ports = append(ports, PortInfo{
|
|
||||||
Port: port,
|
|
||||||
Proto: proto,
|
|
||||||
Process: process,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return ports
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseUFWRules extracts rule lines from `ufw status numbered` output.
|
|
||||||
// Skips the header lines (Status, To, ---, blank lines).
|
|
||||||
func parseUFWRules(output string) []string {
|
|
||||||
var rules []string
|
|
||||||
for _, line := range strings.Split(output, "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Rule lines start with "[ N]" pattern.
|
|
||||||
if strings.HasPrefix(line, "[") && strings.Contains(line, "]") {
|
|
||||||
rules = append(rules, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rules
|
|
||||||
}
|
|
||||||
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