first commit
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# ── Server identity ──────────────────────────────────────────
|
||||
# 32-byte seed as 64 hex chars. Auto-generated into .env on first boot if missing.
|
||||
# Treat SERVER_SEED like a root password (capabilities + admin proofs).
|
||||
# SERVER_SEED=
|
||||
# SERVER_PUBLIC_KEY=
|
||||
|
||||
# ── Roles ────────────────────────────────────────────────────
|
||||
# Baseline role for unknown peers: viewer | operator | admin
|
||||
PEARDATA_DEFAULT_ROLE=viewer
|
||||
|
||||
# Comma-separated peer public keys that always get admin
|
||||
# PEARDATA_ADMIN_KEYS=
|
||||
|
||||
# DEV ONLY — every peer is admin (never enable in production)
|
||||
# PEARDATA_INSECURE_OPEN_ADMIN=1
|
||||
|
||||
# Optional allowlist (empty = all non-revoked peers accepted)
|
||||
# PEARDATA_ALLOWLIST=
|
||||
|
||||
# ── Runtime paths & limits ───────────────────────────────────
|
||||
# Replaces OS home for client identity path:
|
||||
# $PEARDATA_HOME/.config/peardata/identity.json
|
||||
# PEARDATA_HOME=
|
||||
|
||||
# Peer policy + audit.log (default ./data)
|
||||
# PEARDATA_DATA_DIR=./data
|
||||
|
||||
# Demo room ring buffer size
|
||||
# PEARDATA_MAX_MESSAGES=500
|
||||
|
||||
# Per-peer RPC requests per minute
|
||||
# PEARDATA_RATE_LIMIT_RPM=120
|
||||
|
||||
# Client ConnectionManager reconnect attempts
|
||||
# PEARDATA_MAX_RECONNECT=20
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────
|
||||
# LOG_LEVEL=info
|
||||
# LOG_JSON=1
|
||||
|
||||
# ── Healthcheck / soak ───────────────────────────────────────
|
||||
# Remote dial key (falls back to SERVER_PUBLIC_KEY)
|
||||
# PEARDATA_HEALTH_KEY=
|
||||
# HEALTHCHECK_TIMEOUT_MS=8000
|
||||
# SOAK_DURATION_MS=60000
|
||||
# SOAK_INTERVAL_MS=500
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────
|
||||
# SKIP_INTEGRATION=1
|
||||
@@ -0,0 +1,44 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: '--dns-result-order=ipv4first'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
GIT_TERMINAL_PROMPT: '0'
|
||||
npm_config_fetch_retries: '3'
|
||||
npm_config_fetch_timeout: '120000'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config --global url."https://github.com/".insteadOf "ssh://[email protected]/"
|
||||
git config --global url."https://github.com/".insteadOf "[email protected]:"
|
||||
npm install --no-audit --no-fund --loglevel=info
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Healthcheck script loads
|
||||
run: node scripts/healthcheck.js
|
||||
@@ -0,0 +1,75 @@
|
||||
# Rolling release for Gitea (mirrors peardock-style forge pipelines).
|
||||
# Runs on every push to main/master — always rebuilds and republishes the `rolling` tag.
|
||||
#
|
||||
# Secrets:
|
||||
# RELEASE_TOKEN — Gitea PAT with repo release write (required)
|
||||
# GITEA_URL — optional forge base URL (defaults to origin / GITHUB_SERVER_URL)
|
||||
name: Release rolling
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Build artifacts without uploading'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
concurrency:
|
||||
group: release-rolling
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: '--dns-result-order=ipv4first'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install + test
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
GIT_TERMINAL_PROMPT: '0'
|
||||
npm_config_fetch_retries: '3'
|
||||
npm_config_fetch_timeout: '120000'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config --global url."https://github.com/".insteadOf "ssh://[email protected]/"
|
||||
git config --global url."https://github.com/".insteadOf "[email protected]:"
|
||||
npm install --no-audit --no-fund
|
||||
SKIP_INTEGRATION=1 npm test
|
||||
|
||||
- name: Build + publish rolling release
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GITEA_URL: ${{ secrets.GITEA_URL }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
GITEA_SHA: ${{ github.sha }}
|
||||
RELEASE_TAG: rolling
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run == 'true' && '1' || '0' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${DRY_RUN:-0}" != "1" ] && [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||
echo "ERROR: secret RELEASE_TOKEN is not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "${GITHUB_REPOSITORY:-}" ]; then
|
||||
export GITEA_OWNER="${GITHUB_REPOSITORY%%/*}"
|
||||
export GITEA_REPO="${GITHUB_REPOSITORY#*/}"
|
||||
fi
|
||||
if [ -z "${GITEA_URL:-}" ]; then
|
||||
export GITEA_URL="${GITHUB_SERVER_URL:-}"
|
||||
fi
|
||||
chmod +x scripts/gitea-rolling-release.sh scripts/release.sh
|
||||
bash scripts/gitea-rolling-release.sh
|
||||
@@ -0,0 +1,78 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: '--dns-result-order=ipv4first'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
node: ['20', '22']
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js ${{ matrix.node }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config --global url."https://github.com/".insteadOf "ssh://[email protected]/"
|
||||
git config --global url."https://github.com/".insteadOf "[email protected]:"
|
||||
npm install --no-audit --no-fund
|
||||
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
env:
|
||||
# Integration may hang without UDP; unit suite still runs
|
||||
SKIP_INTEGRATION: '0'
|
||||
|
||||
- name: Syntax check entrypoints
|
||||
run: |
|
||||
node --check server/server.js
|
||||
node --check client/connection.js
|
||||
node --check app.js
|
||||
node --check shared/crypto-auth.js
|
||||
|
||||
lint-docs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Required docs present
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for f in \
|
||||
README.md \
|
||||
LICENSE \
|
||||
docs/ARCHITECTURE.md \
|
||||
docs/PROTOCOL.md \
|
||||
docs/GETTING-STARTED.md \
|
||||
docs/SECURITY.md \
|
||||
docs/DESKTOP.md \
|
||||
docs/CONFIGURATION.md \
|
||||
docs/TESTING.md \
|
||||
docs/CI.md \
|
||||
docs/RELEASE.md \
|
||||
docs/EXTENDING.md \
|
||||
.env.example
|
||||
do
|
||||
test -f "$f" || { echo "missing $f"; exit 1; }
|
||||
done
|
||||
# Desktop chrome must stay documented in the HTML shell
|
||||
grep -q 'pear-ctrl' index.html
|
||||
grep -q 'webkit-app-region: drag' ui/styles.css
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Build artifacts without uploading'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install + test
|
||||
run: |
|
||||
npm install --no-audit --no-fund
|
||||
SKIP_INTEGRATION=1 npm test
|
||||
|
||||
- name: Pack source tarball
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME:-manual}"
|
||||
NAME="peardata-${VERSION}"
|
||||
mkdir -p dist
|
||||
tar --exclude=node_modules --exclude=.git --exclude=data --exclude=dist \
|
||||
-czf "dist/${NAME}.tar.gz" .
|
||||
(cd dist && sha256sum "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256")
|
||||
ls -la dist
|
||||
|
||||
- name: Upload GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/') && github.event.inputs.dry_run != 'true'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/*
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
dist/
|
||||
out/
|
||||
coverage/
|
||||
.pear/
|
||||
storage/
|
||||
data/
|
||||
*.seed
|
||||
.cache/
|
||||
tmp/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.tgz
|
||||
package-lock.json.bak
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Pear App Template contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,154 @@
|
||||
# peardata
|
||||
|
||||
**Production-oriented boilerplate for Holepunch / HyperDHT P2P apps.**
|
||||
|
||||
Distilled from patterns used in [peardock](https://github.com/snxraven/peardock)-class apps (MIT template — not a copy of peardock’s AGPL sources):
|
||||
|
||||
| Plane | Stack |
|
||||
|-------|--------|
|
||||
| Transport | **HyperDHT** secret streams (Noise) |
|
||||
| RPC | **protomux-rpc** + compact-encoding JSON |
|
||||
| Identity | Ed25519 keypairs (server seed + client identity file) |
|
||||
| AuthZ | Roles (`viewer` / `operator` / `admin`) + HMAC capabilities + admin seed proof |
|
||||
| Desktop | **Pear** (`pear-electron` + `pear-bridge` + `<pear-ctrl>` titlebar) |
|
||||
| Server | Node 20+ (systemd unit included) |
|
||||
|
||||
The demo product is **PearData**: a multi-peer room with messages, presence, and invite minting. Swap `server/handlers/demo.js` + `server/services/room.js` for your domain.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd pear_app_template # or your clone path
|
||||
npm install
|
||||
|
||||
# Terminal A — server (prints public key)
|
||||
npm run start:server
|
||||
|
||||
# Terminal B — mint an operator invite (optional)
|
||||
npm run mint-invite -- operator
|
||||
|
||||
# Terminal C — Pear desktop UI
|
||||
npm start
|
||||
# or: pear run -d .
|
||||
```
|
||||
|
||||
In the UI, paste the **server public key** (viewer) or a **`pd1.` invite** (elevated role).
|
||||
Paste `SERVER_SEED` from `.env` into “Admin seed” for full admin without an invite.
|
||||
|
||||
Drag the **titlebar** to move the window; resize from the edges (`pear.gui.resizable`).
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run healthcheck
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
pear_app_template/
|
||||
├── app.js # Desktop UI logic
|
||||
├── index.html / index.js # Pear shell (titlebar + pear-ctrl + drag)
|
||||
├── ui/styles.css # Titlebar drag regions + layout
|
||||
├── shared/ # Protocol, encodings, schema, crypto-auth
|
||||
├── server/ # HyperDHT listener + RPC middleware + demo domain
|
||||
│ ├── server.js
|
||||
│ ├── core/ # keys, acl, audit, peer-policy, registry
|
||||
│ ├── rpc/ # PeerSession + register
|
||||
│ ├── handlers/ # Domain RPCs (replace demo.js)
|
||||
│ ├── services/ # Domain state (replace room.js)
|
||||
│ └── utils/
|
||||
├── client/ # Connection, manager, identity
|
||||
├── bin/ # Server binary entry
|
||||
├── scripts/ # healthcheck, soak, mint-invite, rename, release
|
||||
├── test/ # brittle unit + integration
|
||||
├── docs/ # Full documentation set
|
||||
├── deploy/ # systemd unit
|
||||
├── .github/workflows/ # GitHub CI + release
|
||||
└── .gitea/workflows/ # Gitea CI + rolling release skeleton
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## npm scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `npm start` / `npm run dev` | Pear desktop UI |
|
||||
| `npm run start:server` | HyperDHT server |
|
||||
| `npm test` | brittle unit + integration |
|
||||
| `npm run mint-invite -- [role] [ttlMs]` | Offline `pd1.` invite |
|
||||
| `npm run healthcheck` | Liveness / remote ping |
|
||||
| `npm run soak` | Load exercise (needs env keys) |
|
||||
| `npm run rename -- <slug> <Product>` | Rebrand the tree |
|
||||
| `bash scripts/release.sh` | Source tarball + checksum |
|
||||
|
||||
Full env reference: [docs/CONFIGURATION.md](./docs/CONFIGURATION.md).
|
||||
|
||||
---
|
||||
|
||||
## Rebrand for a new app
|
||||
|
||||
```bash
|
||||
npm run rename -- my-app MyApp
|
||||
# → package name, protocol id, env prefixes, invite prefix, product strings
|
||||
```
|
||||
|
||||
Then implement your domain:
|
||||
|
||||
1. Extend `shared/protocol.js` (`MethodRoles`, `Pushes`, `Methods`)
|
||||
2. Validate args in `shared/schema.js`
|
||||
3. Add handlers under `server/handlers/`
|
||||
4. Register them in `server/rpc/register.js`
|
||||
5. Call from `client/` + UI
|
||||
6. Update `docs/PROTOCOL.md` and tests
|
||||
|
||||
See [docs/EXTENDING.md](./docs/EXTENDING.md).
|
||||
|
||||
---
|
||||
|
||||
## Auth model (secure defaults)
|
||||
|
||||
| Mode | How | Role |
|
||||
|------|-----|------|
|
||||
| Viewer | Dial public key only | `viewer` (read) |
|
||||
| Capability | `pd1.` invite or raw HMAC token | grant role |
|
||||
| Admin seed | HMAC proof from `SERVER_SEED` | `admin` |
|
||||
| Allowlist | `PEARDATA_ADMIN_KEYS` | admin for listed peers |
|
||||
| Dev escape | `PEARDATA_INSECURE_OPEN_ADMIN=1` | everyone admin |
|
||||
|
||||
See [docs/SECURITY.md](./docs/SECURITY.md).
|
||||
|
||||
---
|
||||
|
||||
## CI
|
||||
|
||||
- **GitHub**: `.github/workflows/ci.yml` (Node 20/22 matrix + docs presence), `release.yml` on `v*` tags
|
||||
- **Gitea**: `.gitea/workflows/ci.yml`, `release-rolling.yml` (every push to `main` → `rolling` release; needs `RELEASE_TOKEN`)
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Contents |
|
||||
|-----|----------|
|
||||
| [Getting started](./docs/GETTING-STARTED.md) | Install, run, connect, systemd, troubleshooting |
|
||||
| [Desktop](./docs/DESKTOP.md) | Pear shell, `pear-ctrl`, drag/resize, identity |
|
||||
| [Architecture](./docs/ARCHITECTURE.md) | Planes, boot, session pipeline, module map |
|
||||
| [Protocol](./docs/PROTOCOL.md) | Methods, pushes, errors, versioning |
|
||||
| [Security](./docs/SECURITY.md) | Threat model, secrets, hardening checklist |
|
||||
| [Configuration](./docs/CONFIGURATION.md) | Full environment + scripts reference |
|
||||
| [Testing](./docs/TESTING.md) | brittle suite, soak, manual checks |
|
||||
| [CI](./docs/CI.md) | Pipelines and required docs |
|
||||
| [Release](./docs/RELEASE.md) | Version, tag, tarball, rollback |
|
||||
| [Extending](./docs/EXTENDING.md) | Grow past the demo room |
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — use this as a starting point for proprietary or open apps.
|
||||
(Peardock itself is AGPL; this template does **not** copy peardock source verbatim and is intentionally MIT.)
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* PearData desktop — multi-peer fleet overview + live charts.
|
||||
*/
|
||||
import { manager } from './client/manager.js'
|
||||
import { Methods, Pushes } from './shared/protocol.js'
|
||||
import { getClientIdentity } from './client/identity.js'
|
||||
|
||||
const $ = (id) => document.getElementById(id)
|
||||
|
||||
const els = {
|
||||
connectInput: $('connect-input'),
|
||||
adminSeed: $('admin-seed'),
|
||||
btnConnect: $('btn-connect'),
|
||||
btnDisconnect: $('btn-disconnect'),
|
||||
btnInvite: $('btn-invite'),
|
||||
peerList: $('peer-list'),
|
||||
serverInfo: $('server-info'),
|
||||
log: $('log'),
|
||||
status: $('status-chip'),
|
||||
connMeta: $('conn-meta'),
|
||||
roleBadge: $('role-badge'),
|
||||
inviteOut: $('invite-out'),
|
||||
anomalyList: $('anomaly-list'),
|
||||
statCpu: $('stat-cpu'),
|
||||
statRam: $('stat-ram'),
|
||||
statLoad: $('stat-load'),
|
||||
statNet: $('stat-net'),
|
||||
statHealth: $('stat-health'),
|
||||
}
|
||||
|
||||
/** @type {Record<string, number[]>} */
|
||||
const series = {
|
||||
cpu: [],
|
||||
ram: [],
|
||||
net: [],
|
||||
io: [],
|
||||
}
|
||||
const SERIES_MAX = 60
|
||||
|
||||
function log(line) {
|
||||
const ts = new Date().toLocaleTimeString()
|
||||
els.log.textContent = `[${ts}] ${line}\n` + els.log.textContent
|
||||
}
|
||||
|
||||
function setOnline(online) {
|
||||
els.status.textContent = online ? 'live' : 'offline'
|
||||
els.status.classList.toggle('online', online)
|
||||
els.status.classList.toggle('offline', !online)
|
||||
els.btnDisconnect.disabled = !online
|
||||
els.btnInvite.disabled = !online
|
||||
els.btnConnect.disabled = online
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function pushPoint(key, value) {
|
||||
const arr = series[key]
|
||||
arr.push(Number(value) || 0)
|
||||
while (arr.length > SERIES_MAX) arr.shift()
|
||||
}
|
||||
|
||||
function drawChart(canvasId, values, color = '#5b8cff') {
|
||||
const canvas = $(canvasId)
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext('2d')
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = canvas.clientWidth || 320
|
||||
const h = canvas.height
|
||||
canvas.width = w * dpr
|
||||
canvas.height = h * dpr
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
ctx.strokeStyle = 'rgba(139,155,184,0.15)'
|
||||
ctx.lineWidth = 1
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const y = (h / 4) * i
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, y)
|
||||
ctx.lineTo(w, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
if (values.length < 2) return
|
||||
const max = Math.max(...values, 1)
|
||||
const min = Math.min(...values, 0)
|
||||
const span = max - min || 1
|
||||
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
values.forEach((v, i) => {
|
||||
const x = (i / (SERIES_MAX - 1)) * w
|
||||
const y = h - ((v - min) / span) * (h - 8) - 4
|
||||
if (i === 0) ctx.moveTo(x, y)
|
||||
else ctx.lineTo(x, y)
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
// fill
|
||||
ctx.lineTo(w, h)
|
||||
ctx.lineTo(0, h)
|
||||
ctx.closePath()
|
||||
ctx.fillStyle = color + '22'
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
function renderPeers() {
|
||||
const list = manager.list?.() || []
|
||||
els.peerList.innerHTML = ''
|
||||
if (!list.length) {
|
||||
const li = document.createElement('li')
|
||||
li.className = 'muted'
|
||||
li.textContent = 'No agents connected'
|
||||
els.peerList.appendChild(li)
|
||||
return
|
||||
}
|
||||
for (const p of list) {
|
||||
const li = document.createElement('li')
|
||||
const active = manager.activeId === p.id
|
||||
li.className = active ? 'active' : ''
|
||||
li.innerHTML = `<span>${escapeHtml(p.id)}</span><span class="muted">${p.connected ? 'live' : '…'}</span>`
|
||||
li.addEventListener('click', () => {
|
||||
manager.setActive?.(p.id)
|
||||
renderPeers()
|
||||
})
|
||||
els.peerList.appendChild(li)
|
||||
}
|
||||
}
|
||||
|
||||
function onSamples(samples) {
|
||||
for (const s of samples || []) {
|
||||
if (s.chart === 'system.cpu') {
|
||||
const used = 100 - (s.values.idle ?? 100)
|
||||
pushPoint('cpu', used)
|
||||
els.statCpu.textContent = `${used.toFixed(1)}%`
|
||||
}
|
||||
if (s.chart === 'system.ram') {
|
||||
const used = s.values.used ?? 0
|
||||
pushPoint('ram', used)
|
||||
els.statRam.textContent = `${used.toFixed(0)} MiB`
|
||||
}
|
||||
if (s.chart === 'system.load') {
|
||||
els.statLoad.textContent = (s.values.load1 ?? 0).toFixed(2)
|
||||
}
|
||||
if (s.chart === 'system.net') {
|
||||
const rx = s.values.received ?? 0
|
||||
pushPoint('net', rx)
|
||||
els.statNet.textContent = `${rx.toFixed(1)} kb/s`
|
||||
}
|
||||
if (s.chart === 'system.io') {
|
||||
pushPoint('io', (s.values.reads || 0) + (s.values.writes || 0))
|
||||
}
|
||||
}
|
||||
drawChart('chart-cpu', series.cpu, '#5b8cff')
|
||||
drawChart('chart-ram', series.ram, '#3dd6c6')
|
||||
drawChart('chart-net', series.net, '#f0b429')
|
||||
drawChart('chart-io', series.io, '#ff6b7a')
|
||||
}
|
||||
|
||||
function prependAnomaly(ev) {
|
||||
const li = document.createElement('li')
|
||||
li.className = ev.severity === 'critical' ? 'crit' : ev.cleared ? 'ok' : 'warn'
|
||||
li.innerHTML = `<strong>${escapeHtml(ev.severity || 'event')}</strong> ${escapeHtml(ev.message || '')}
|
||||
<span class="muted">${new Date(ev.ts || Date.now()).toLocaleTimeString()}</span>`
|
||||
els.anomalyList.prepend(li)
|
||||
while (els.anomalyList.children.length > 40) els.anomalyList.lastChild.remove()
|
||||
}
|
||||
|
||||
async function refreshMeta() {
|
||||
const [info, auth, health, node] = await Promise.all([
|
||||
manager.request(Methods.getServerInfo, {}),
|
||||
manager.request(Methods.getAuthStatus, {}),
|
||||
manager.request(Methods.getHealth, {}),
|
||||
manager.request(Methods.getNodeInfo, {}),
|
||||
])
|
||||
els.serverInfo.textContent = JSON.stringify({ info, node, health }, null, 2)
|
||||
els.roleBadge.textContent = auth.role || '—'
|
||||
els.statHealth.textContent = health.status || '—'
|
||||
els.statHealth.parentElement.dataset.health = health.status || ''
|
||||
const id = getClientIdentity()
|
||||
els.connMeta.textContent = `you ${id.publicKeyHex.slice(0, 12)}… · ${auth.role} · ${auth.authMode}`
|
||||
renderPeers()
|
||||
}
|
||||
|
||||
function parseConnectInput(raw) {
|
||||
const input = raw.trim()
|
||||
if (input.startsWith('pd1.')) {
|
||||
const inv = decodeInvite(input)
|
||||
return {
|
||||
publicKeyHex: inv.publicKeyHex,
|
||||
capability: inv.capability,
|
||||
}
|
||||
}
|
||||
return { publicKeyHex: input.toLowerCase(), capability: null }
|
||||
}
|
||||
|
||||
els.btnConnect.addEventListener('click', async () => {
|
||||
const raw = els.connectInput.value.trim()
|
||||
const adminSeed = els.adminSeed.value.trim() || null
|
||||
if (!raw) {
|
||||
log('Enter a public key or pd1 invite')
|
||||
return
|
||||
}
|
||||
els.btnConnect.disabled = true
|
||||
try {
|
||||
const { publicKeyHex, capability } = parseConnectInput(raw)
|
||||
log(`Dialing ${publicKeyHex.slice(0, 16)}…`)
|
||||
await manager.connect(publicKeyHex, { capability, adminSeed })
|
||||
setOnline(true)
|
||||
await manager.request(Methods.subscribeMetrics, { charts: ['*'], intervalMs: 1000 })
|
||||
await manager.request(Methods.subscribeAnomalies, {})
|
||||
await refreshMeta()
|
||||
|
||||
// seed charts from history
|
||||
for (const chart of ['system.cpu', 'system.ram', 'system.net', 'system.io']) {
|
||||
try {
|
||||
const q = await manager.request(Methods.queryData, { chart, after: -60, points: 60 })
|
||||
const dimIdx = chart === 'system.cpu' ? q.labels.indexOf('user') : 1
|
||||
for (const row of q.data || []) {
|
||||
if (chart === 'system.cpu') {
|
||||
const idleIdx = q.labels.indexOf('idle')
|
||||
const idle = idleIdx >= 0 ? row[idleIdx] : 100
|
||||
pushPoint('cpu', 100 - (idle ?? 100))
|
||||
} else if (dimIdx >= 0) {
|
||||
const key = chart === 'system.ram' ? 'ram' : chart === 'system.net' ? 'net' : 'io'
|
||||
pushPoint(key, row[dimIdx] ?? 0)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
drawChart('chart-cpu', series.cpu, '#5b8cff')
|
||||
drawChart('chart-ram', series.ram, '#3dd6c6')
|
||||
drawChart('chart-net', series.net, '#f0b429')
|
||||
drawChart('chart-io', series.io, '#ff6b7a')
|
||||
log('Subscribed to live metrics')
|
||||
} catch (err) {
|
||||
log(`Connect failed: ${err.message}`)
|
||||
setOnline(false)
|
||||
els.btnConnect.disabled = false
|
||||
}
|
||||
})
|
||||
|
||||
els.btnDisconnect.addEventListener('click', async () => {
|
||||
await manager.disconnectAll?.()
|
||||
setOnline(false)
|
||||
renderPeers()
|
||||
log('Disconnected')
|
||||
})
|
||||
|
||||
els.btnInvite.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await manager.request(Methods.mintInvite, { role: 'operator' })
|
||||
els.inviteOut.classList.remove('hidden')
|
||||
els.inviteOut.textContent = res.invite
|
||||
log('Invite minted')
|
||||
} catch (err) {
|
||||
log(`Invite failed: ${err.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
manager.on?.('push', (ev) => {
|
||||
if (ev.type === Pushes.metrics) onSamples(ev.data?.samples)
|
||||
if (ev.type === Pushes.anomaly) {
|
||||
prependAnomaly(ev.data)
|
||||
log(`Anomaly: ${ev.data?.message || ''}`)
|
||||
}
|
||||
if (ev.type === Pushes.health) {
|
||||
els.statHealth.textContent = ev.data?.status || '—'
|
||||
}
|
||||
})
|
||||
|
||||
// Compatibility if manager emits per-connection
|
||||
manager.on?.('connection', (conn) => {
|
||||
conn.on?.(Pushes.metrics, (data) => onSamples(data?.samples))
|
||||
conn.on?.(Pushes.anomaly, (data) => prependAnomaly(data))
|
||||
conn.on?.(Pushes.health, (data) => {
|
||||
els.statHealth.textContent = data?.status || '—'
|
||||
})
|
||||
conn.on?.('disconnected', () => {
|
||||
setOnline(false)
|
||||
log('Agent disconnected')
|
||||
})
|
||||
})
|
||||
|
||||
setOnline(false)
|
||||
renderPeers()
|
||||
log('PearData ready — connect an agent public key')
|
||||
|
||||
// redraw on resize
|
||||
window.addEventListener('resize', () => {
|
||||
drawChart('chart-cpu', series.cpu, '#5b8cff')
|
||||
drawChart('chart-ram', series.ram, '#3dd6c6')
|
||||
drawChart('chart-net', series.net, '#f0b429')
|
||||
drawChart('chart-io', series.io, '#ff6b7a')
|
||||
})
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Server binary entry (symlink-friendly).
|
||||
*/
|
||||
import '../server/server.js'
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Single server connection via HyperDHT + protomux-rpc.
|
||||
*
|
||||
* Auth modes:
|
||||
* - viewer: public key only
|
||||
* - seed: adminProof HMAC from SERVER_SEED
|
||||
* - capability: HMAC grant (from pa1 invite or direct)
|
||||
*/
|
||||
import DHT from 'hyperdht'
|
||||
import ProtomuxRPC from 'protomux-rpc'
|
||||
import b4a from 'b4a'
|
||||
import { EventEmitter } from 'events'
|
||||
import { PROTOCOL, Pushes, Methods, APP_NAME, APP_VERSION } from '../shared/protocol.js'
|
||||
import { encodings } from '../shared/encodings.js'
|
||||
import { normalizeRpcError, unwrapError } from './errors.js'
|
||||
import { getClientIdentity } from './identity.js'
|
||||
import { createAdminProof } from '../shared/crypto-auth.js'
|
||||
|
||||
/**
|
||||
* @typedef {object} ConnectionOptions
|
||||
* @property {number} [timeoutMs=30000]
|
||||
* @property {string|null} [capability]
|
||||
* @property {string|null} [adminSeed]
|
||||
*/
|
||||
|
||||
export class PearDataConnection extends EventEmitter {
|
||||
/**
|
||||
* @param {string} publicKeyHex
|
||||
* @param {ConnectionOptions} [opts]
|
||||
*/
|
||||
constructor(publicKeyHex, opts = {}) {
|
||||
super()
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(publicKeyHex)) {
|
||||
throw new Error('Server public key must be 64 hex characters')
|
||||
}
|
||||
this.publicKeyHex = publicKeyHex.toLowerCase()
|
||||
this.publicKey = b4a.from(this.publicKeyHex, 'hex')
|
||||
this.id = this.publicKeyHex.slice(0, 12)
|
||||
this.timeoutMs = opts.timeoutMs ?? 30000
|
||||
this.capability = opts.capability || null
|
||||
this.adminSeed = opts.adminSeed || null
|
||||
|
||||
this.dht = null
|
||||
this.socket = null
|
||||
this.rpc = null
|
||||
this.connected = false
|
||||
this.connectedAt = null
|
||||
this.latency = null
|
||||
/** @type {'idle'|'dialing'|'handshaking'|'ready'|'closed'} */
|
||||
this.state = 'idle'
|
||||
this.role = null
|
||||
this.authMode = null
|
||||
this.protocolVersion = null
|
||||
this.clientPublicKeyHex = null
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (this.connected) return this
|
||||
|
||||
this.state = 'dialing'
|
||||
try {
|
||||
const identity = getClientIdentity()
|
||||
this.clientPublicKeyHex = identity.publicKeyHex
|
||||
this.dht = new DHT({ keyPair: identity.keyPair })
|
||||
this.socket = this.dht.connect(this.publicKey)
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true
|
||||
cleanup()
|
||||
const err = new Error(`Connection timeout after ${this.timeoutMs}ms`)
|
||||
err.code = 'CONNECTION_TIMEOUT'
|
||||
reject(err)
|
||||
}
|
||||
}, this.timeoutMs)
|
||||
|
||||
const onOpen = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const onError = (err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
const onClose = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
cleanup()
|
||||
reject(new Error('Connection closed before open'))
|
||||
}
|
||||
const cleanup = () => {
|
||||
this.socket?.off?.('open', onOpen)
|
||||
this.socket?.off?.('connect', onOpen)
|
||||
this.socket?.off?.('error', onError)
|
||||
this.socket?.off?.('close', onClose)
|
||||
}
|
||||
|
||||
this.socket.once('open', onOpen)
|
||||
this.socket.once('connect', onOpen)
|
||||
this.socket.once('error', onError)
|
||||
this.socket.once('close', onClose)
|
||||
|
||||
if (this.socket.publicKey && this.socket.rawStream) onOpen()
|
||||
})
|
||||
|
||||
this.rpc = new ProtomuxRPC(this.socket, {
|
||||
id: this.publicKey,
|
||||
protocol: PROTOCOL,
|
||||
...encodings,
|
||||
})
|
||||
|
||||
await this.rpc.fullyOpened?.().catch(() => {})
|
||||
this._registerPushHandlers()
|
||||
|
||||
this.socket.on('close', () => this._onDisconnect())
|
||||
this.socket.on('error', (err) => {
|
||||
this.emit('error', err)
|
||||
if (this.connected) this._onDisconnect()
|
||||
})
|
||||
this.rpc.on('close', () => this._onDisconnect())
|
||||
|
||||
this.state = 'handshaking'
|
||||
const hsArgs = {
|
||||
clientName: APP_NAME,
|
||||
clientVersion: APP_VERSION,
|
||||
}
|
||||
if (this.capability) hsArgs.capability = this.capability
|
||||
if (this.adminSeed) {
|
||||
hsArgs.adminProof = createAdminProof(this.adminSeed, {
|
||||
peerId: this.clientPublicKeyHex,
|
||||
serverPublicKeyHex: this.publicKeyHex,
|
||||
})
|
||||
}
|
||||
|
||||
let hs
|
||||
try {
|
||||
hs = await this.request(Methods.handshake, hsArgs)
|
||||
} catch (err) {
|
||||
const root = unwrapError(err)
|
||||
const code = root?.code || err?.code
|
||||
const msg = String(root?.message || err?.message || '')
|
||||
const softSpent =
|
||||
this.capability &&
|
||||
(code === 'CAPABILITY_SPENT' ||
|
||||
code === 'CAPABILITY_EXPIRED' ||
|
||||
/already used or revoked|Capability expired/i.test(msg))
|
||||
if (softSpent) {
|
||||
this.capability = null
|
||||
const retryArgs = {
|
||||
clientName: APP_NAME,
|
||||
clientVersion: APP_VERSION,
|
||||
}
|
||||
if (this.adminSeed) {
|
||||
retryArgs.adminProof = createAdminProof(this.adminSeed, {
|
||||
peerId: this.clientPublicKeyHex,
|
||||
serverPublicKeyHex: this.publicKeyHex,
|
||||
})
|
||||
}
|
||||
hs = await this.request(Methods.handshake, retryArgs)
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
this.role = hs?.role || null
|
||||
this.authMode = hs?.auth?.mode || null
|
||||
this.protocolVersion = hs?.protocolVersion ?? null
|
||||
this.connected = true
|
||||
this.connectedAt = Date.now()
|
||||
this.state = 'ready'
|
||||
this.emit('connected', hs)
|
||||
return this
|
||||
} catch (err) {
|
||||
this.state = 'closed'
|
||||
await this.destroy().catch(() => {})
|
||||
throw normalizeRpcError(err, 'connect')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {object} [args]
|
||||
*/
|
||||
async request(method, args = {}) {
|
||||
if (!this.rpc) {
|
||||
const err = new Error('Not connected')
|
||||
err.code = 'NOT_CONNECTED'
|
||||
throw err
|
||||
}
|
||||
try {
|
||||
const t0 = Date.now()
|
||||
const res = await this.rpc.request(method, args, encodings)
|
||||
this.latency = Date.now() - t0
|
||||
return res
|
||||
} catch (err) {
|
||||
throw normalizeRpcError(err, method)
|
||||
}
|
||||
}
|
||||
|
||||
async ping() {
|
||||
return this.request(Methods.ping, {})
|
||||
}
|
||||
|
||||
_registerPushHandlers() {
|
||||
for (const push of Object.values(Pushes)) {
|
||||
this.rpc.on(push, (data) => {
|
||||
this.emit('push', { type: push, data })
|
||||
this.emit(push, data)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_onDisconnect() {
|
||||
if (!this.connected && this.state === 'closed') return
|
||||
const was = this.connected
|
||||
this.connected = false
|
||||
this.state = 'closed'
|
||||
if (was) this.emit('disconnected')
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
this.connected = false
|
||||
this.state = 'closed'
|
||||
try {
|
||||
this.rpc?.destroy?.()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
this.socket?.destroy?.()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await this.dht?.destroy?.()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.rpc = null
|
||||
this.socket = null
|
||||
this.dht = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Normalize protomux-rpc / DHT errors for UI.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {unknown} err
|
||||
* @returns {Error}
|
||||
*/
|
||||
export function unwrapError(err) {
|
||||
let cur = err
|
||||
let depth = 0
|
||||
while (cur?.cause && depth < 5) {
|
||||
cur = cur.cause
|
||||
depth++
|
||||
}
|
||||
return cur instanceof Error ? cur : new Error(String(cur?.message || cur || 'Unknown error'))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} err
|
||||
* @param {string} [method]
|
||||
*/
|
||||
export function normalizeRpcError(err, method) {
|
||||
const root = unwrapError(err)
|
||||
const e = new Error(root.message || String(err))
|
||||
e.code = root.code || err?.code || 'RPC_ERROR'
|
||||
e.method = method || root.method || null
|
||||
e.cause = err
|
||||
return e
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Persistent client DHT identity for stable peerId across reconnects.
|
||||
* Stored at ~/.config/peardata/identity.json (mode 0600).
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import crypto from 'crypto'
|
||||
import DHT from 'hyperdht'
|
||||
import b4a from 'b4a'
|
||||
|
||||
const IDENTITY_VERSION = 1
|
||||
|
||||
export function getIdentityPath() {
|
||||
const home =
|
||||
process.env.PEARDATA_HOME ||
|
||||
process.env.HOME ||
|
||||
process.env.USERPROFILE ||
|
||||
(typeof os.homedir === 'function' ? os.homedir() : '') ||
|
||||
''
|
||||
return path.join(home, '.config', 'peardata', 'identity.json')
|
||||
}
|
||||
|
||||
export function loadOrCreateClientIdentity() {
|
||||
const filePath = getIdentityPath()
|
||||
let seedHex = null
|
||||
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
if (raw?.seedHex && /^[0-9a-fA-F]{64}$/.test(raw.seedHex)) {
|
||||
seedHex = String(raw.seedHex).toLowerCase()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// regenerate
|
||||
}
|
||||
|
||||
if (!seedHex) {
|
||||
seedHex = crypto.randomBytes(32).toString('hex')
|
||||
try {
|
||||
const dir = path.dirname(filePath)
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: IDENTITY_VERSION,
|
||||
seedHex,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
try {
|
||||
fs.chmodSync(filePath, 0o600)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {
|
||||
// In-memory only if FS unavailable
|
||||
}
|
||||
}
|
||||
|
||||
const seed = b4a.from(seedHex, 'hex')
|
||||
const keyPair = DHT.keyPair(seed)
|
||||
const publicKeyHex = b4a.toString(keyPair.publicKey, 'hex')
|
||||
return { seed, keyPair, publicKeyHex, seedHex }
|
||||
}
|
||||
|
||||
let cached = null
|
||||
|
||||
export function getClientIdentity() {
|
||||
if (!cached) cached = loadOrCreateClientIdentity()
|
||||
return cached
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Client barrel export.
|
||||
*/
|
||||
export { PearDataConnection } from './connection.js'
|
||||
export { ConnectionManager, manager } from './manager.js'
|
||||
export { getClientIdentity, getIdentityPath } from './identity.js'
|
||||
export { normalizeRpcError, unwrapError } from './errors.js'
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Multi-connection manager with reconnect + active selection.
|
||||
*/
|
||||
import { EventEmitter } from 'events'
|
||||
import { PearDataConnection } from './connection.js'
|
||||
import { classifyConnectionInput } from '../shared/crypto-auth.js'
|
||||
|
||||
export class ConnectionManager extends EventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
/** @type {Map<string, PearDataConnection>} */
|
||||
this.connections = new Map()
|
||||
/** @type {PearDataConnection|null} */
|
||||
this.active = null
|
||||
/** @type {Map<string, ReturnType<typeof setTimeout>>} */
|
||||
this._reconnectTimers = new Map()
|
||||
this.maxReconnectTries = Number(process.env.PEARDATA_MAX_RECONNECT) || 20
|
||||
/** @type {Map<string, number>} */
|
||||
this._tries = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} input - public key, pa1 invite, or capability+key object fields
|
||||
* @param {{ adminSeed?: string, alias?: string, autoReconnect?: boolean }} [opts]
|
||||
*/
|
||||
async connect(input, opts = {}) {
|
||||
const parsed = typeof input === 'string' ? classifyConnectionInput(input) : input
|
||||
let publicKeyHex
|
||||
let capability = opts.capability || null
|
||||
|
||||
if (parsed.kind === 'invite') {
|
||||
publicKeyHex = parsed.publicKeyHex
|
||||
capability = parsed.capability
|
||||
} else if (parsed.kind === 'publicKey') {
|
||||
publicKeyHex = parsed.publicKeyHex
|
||||
} else if (parsed.publicKeyHex) {
|
||||
publicKeyHex = parsed.publicKeyHex
|
||||
capability = parsed.capability || capability
|
||||
} else {
|
||||
throw new Error(parsed.error || 'Invalid connection input')
|
||||
}
|
||||
|
||||
publicKeyHex = String(publicKeyHex).toLowerCase()
|
||||
await this.disconnect(publicKeyHex)
|
||||
|
||||
const conn = new PearDataConnection(publicKeyHex, {
|
||||
capability,
|
||||
adminSeed: opts.adminSeed || null,
|
||||
})
|
||||
|
||||
conn.on('disconnected', () => {
|
||||
this.emit('disconnected', conn)
|
||||
if (opts.autoReconnect !== false) this._scheduleReconnect(publicKeyHex, opts)
|
||||
})
|
||||
conn.on('push', (ev) => this.emit('push', ev, conn))
|
||||
conn.on('error', (err) => this.emit('error', err, conn))
|
||||
|
||||
await conn.connect()
|
||||
this.connections.set(publicKeyHex, conn)
|
||||
this._tries.set(publicKeyHex, 0)
|
||||
this.setActive(publicKeyHex)
|
||||
this.emit('connected', conn)
|
||||
return conn
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} publicKeyHex
|
||||
*/
|
||||
setActive(publicKeyHex) {
|
||||
const conn = this.connections.get(String(publicKeyHex).toLowerCase())
|
||||
if (!conn) return false
|
||||
this.active = conn
|
||||
this.emit('active', conn)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {object} [args]
|
||||
*/
|
||||
async request(method, args) {
|
||||
if (!this.active?.connected) {
|
||||
const err = new Error('No active connection')
|
||||
err.code = 'NOT_CONNECTED'
|
||||
throw err
|
||||
}
|
||||
return this.active.request(method, args)
|
||||
}
|
||||
|
||||
list() {
|
||||
return [...this.connections.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} [publicKeyHex]
|
||||
*/
|
||||
async disconnect(publicKeyHex) {
|
||||
if (!publicKeyHex) {
|
||||
for (const id of [...this.connections.keys()]) await this.disconnect(id)
|
||||
return
|
||||
}
|
||||
const id = String(publicKeyHex).toLowerCase()
|
||||
const timer = this._reconnectTimers.get(id)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
this._reconnectTimers.delete(id)
|
||||
}
|
||||
const conn = this.connections.get(id)
|
||||
if (conn) {
|
||||
this.connections.delete(id)
|
||||
if (this.active === conn) this.active = null
|
||||
await conn.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
_scheduleReconnect(publicKeyHex, opts) {
|
||||
const id = String(publicKeyHex).toLowerCase()
|
||||
if (this._reconnectTimers.has(id)) return
|
||||
const tries = (this._tries.get(id) || 0) + 1
|
||||
this._tries.set(id, tries)
|
||||
if (tries > this.maxReconnectTries) {
|
||||
this.emit('reconnect-exhausted', { publicKeyHex: id, tries })
|
||||
return
|
||||
}
|
||||
const delay = Math.min(30_000, 1000 * 2 ** Math.min(tries, 5))
|
||||
const timer = setTimeout(async () => {
|
||||
this._reconnectTimers.delete(id)
|
||||
try {
|
||||
await this.connect(id, { ...opts, autoReconnect: true })
|
||||
} catch (err) {
|
||||
this.emit('reconnect-failed', { publicKeyHex: id, err, tries })
|
||||
this._scheduleReconnect(id, opts)
|
||||
}
|
||||
}, delay)
|
||||
this._reconnectTimers.set(id, timer)
|
||||
}
|
||||
}
|
||||
|
||||
export const manager = new ConnectionManager()
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Pear App P2P server (HyperDHT + protomux-rpc)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/peardata
|
||||
EnvironmentFile=-/opt/peardata/.env
|
||||
ExecStart=/usr/bin/node /opt/peardata/server/server.js
|
||||
ExecStartPost=/usr/bin/node /opt/peardata/scripts/healthcheck.js
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
# Hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/opt/peardata/data /opt/peardata/.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,193 @@
|
||||
# Architecture
|
||||
|
||||
## Design goals
|
||||
|
||||
1. **No central control plane** — peers dial a public key, not a SaaS tenant.
|
||||
2. **Cryptographic identity** — HyperDHT Noise streams authenticate both ends.
|
||||
3. **Clear AuthZ** — roles, rate limits, audit, optional allowlist / revoke.
|
||||
4. **Shared wire contract** — `shared/*` is the single source of truth for client + server.
|
||||
5. **Replaceable domain** — demo room is a thin layer over the session stack.
|
||||
6. **Pear-native desktop** — `pear-electron` shell with `<pear-ctrl>` window chrome.
|
||||
|
||||
## System context
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI[Pear desktop / scripts] -->|HyperDHT Noise| SRV[Node server]
|
||||
SRV --> STATE[Room / your domain]
|
||||
UI -.->|bootstrap / punch| NET[HyperDHT network]
|
||||
SRV -.-> NET
|
||||
```
|
||||
|
||||
## Layered stack
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Presentation
|
||||
HTML[index.html + app.js + ui/styles.css]
|
||||
PEAR[index.js pear-electron + bridge]
|
||||
end
|
||||
subgraph ClientCore
|
||||
MGR[client/manager.js]
|
||||
CON[client/connection.js]
|
||||
ID[client/identity.js]
|
||||
end
|
||||
subgraph Wire
|
||||
PROT[shared/protocol.js]
|
||||
ENC[shared/encodings.js]
|
||||
AUTH[shared/crypto-auth.js]
|
||||
SCH[shared/schema.js]
|
||||
end
|
||||
subgraph ServerCore
|
||||
BOOT[server/server.js]
|
||||
SESS[server/rpc/session.js]
|
||||
ACL[server/core/acl.js]
|
||||
HAND[server/handlers/*]
|
||||
SVC[server/services/*]
|
||||
end
|
||||
PEAR --> HTML
|
||||
HTML --> MGR --> CON
|
||||
CON --> PROT
|
||||
CON --> AUTH
|
||||
SESS --> PROT
|
||||
SESS --> ACL
|
||||
SESS --> SCH
|
||||
HAND --> SVC
|
||||
BOOT --> SESS
|
||||
CON <-->|secret stream| SESS
|
||||
```
|
||||
|
||||
## Process model
|
||||
|
||||
| Process | Entry | Responsibility |
|
||||
|---------|-------|----------------|
|
||||
| **Server** | `server/server.js` or `bin/peardata-server.mjs` | HyperDHT listen, RPC, domain state |
|
||||
| **Desktop** | `index.js` → Pear Runtime | Window + HTML UI; dials servers as a client |
|
||||
| **Scripts** | `scripts/*` | mint-invite, healthcheck, soak (use client stack) |
|
||||
|
||||
Server and desktop are **independent**. You can run many clients against one server, or headless scripts with no UI.
|
||||
|
||||
## Server boot
|
||||
|
||||
1. `loadOrCreateKeyPair()` → persist `SERVER_SEED` / `SERVER_PUBLIC_KEY` in `.env`
|
||||
2. `initAuthKeys()` → HKDF MAC key for capabilities
|
||||
3. `loadPeerPolicy()` → roles / revocations / spent JTIs from `PEARDATA_DATA_DIR`
|
||||
4. `dht.createServer().listen(keyPair)`
|
||||
5. On connection → revoke check → `PeerSession` → `registerAllHandlers` → peer registry
|
||||
6. Banner logs public key + secure/insecure mode
|
||||
7. `graceful-goodbye` / SIGINT / SIGTERM drain peers and destroy DHT
|
||||
|
||||
## Session middleware (every non-hot RPC)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
IN[method + args] --> RL{Rate limit}
|
||||
RL -->|deny| E1[RATE_LIMIT_EXCEEDED]
|
||||
RL -->|ok| ACL{roleAllows MethodRoles}
|
||||
ACL -->|deny| E2[PERMISSION_DENIED + audit]
|
||||
ACL -->|ok| VAL{validateMethodArgs}
|
||||
VAL -->|fail| E3[INVALID_ARGS]
|
||||
VAL -->|ok| H[Handler]
|
||||
H --> OK[Result + optional audit]
|
||||
```
|
||||
|
||||
**Hot path** (`session.respond(method, handler, { hot: true })` or stream method names):
|
||||
|
||||
- Still rate-limited and ACL-checked
|
||||
- Skips full schema validation / success audit (for high-frequency streams)
|
||||
|
||||
## Client connection states
|
||||
|
||||
```
|
||||
idle → dialing → handshaking → ready
|
||||
↘ closed → (manager reconnect timer)
|
||||
```
|
||||
|
||||
| State | Meaning |
|
||||
|-------|---------|
|
||||
| `idle` | Constructed, not dialing |
|
||||
| `dialing` | `dht.connect(serverPk)` in flight |
|
||||
| `handshaking` | Stream open; `handshake` RPC |
|
||||
| `ready` | Authenticated; RPCs allowed |
|
||||
| `closed` | Torn down |
|
||||
|
||||
`ConnectionManager` tracks multiple peers, active selection, and reconnect (max `PEARDATA_MAX_RECONNECT`).
|
||||
|
||||
## Identity planes
|
||||
|
||||
| Plane | Storage | Purpose |
|
||||
|-------|---------|---------|
|
||||
| **Server keypair** | `.env` (`SERVER_SEED`) | DHT listen address + HMAC root |
|
||||
| **Client keypair** | `~/.config/peardata/identity.json` | Stable peerId for AuthZ / revoke |
|
||||
| **Capabilities** | Issued as tokens / `pd1.` invites | Role grants with optional expiry & peer bind |
|
||||
| **Peer policy** | `data/peer-policy.json` | Registered roles, revocations, spent JTIs |
|
||||
| **Audit** | `data/audit.log` | Mutating RPC trail |
|
||||
|
||||
## Module map
|
||||
|
||||
### `shared/`
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `protocol.js` | `PROTOCOL`, roles, `MethodRoles`, `Methods`, `Pushes` |
|
||||
| `encodings.js` | compact-encoding JSON for protomux-rpc |
|
||||
| `crypto-auth.js` | MAC key, capabilities, admin proof, invites |
|
||||
| `schema.js` | Lightweight request validation |
|
||||
|
||||
### `server/`
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `server.js` | Boot + DHT accept loop |
|
||||
| `core/keys.js` | Seed load / generate |
|
||||
| `core/auth-keys.js` | Process-wide MAC key |
|
||||
| `core/acl.js` | Role resolution + assert |
|
||||
| `core/peer-policy.js` | File-backed policy |
|
||||
| `core/peer-registry.js` | Live sessions |
|
||||
| `core/audit.js` | Audit log writer |
|
||||
| `rpc/session.js` | ProtomuxRPC + middleware |
|
||||
| `rpc/register.js` | Wire handlers per session |
|
||||
| `handlers/demo.js` | **Replace** — domain RPCs |
|
||||
| `services/room.js` | **Replace** — domain state |
|
||||
| `utils/logger.js` | Structured / pretty logs |
|
||||
| `utils/rateLimiter.js` | Per-peer RPM |
|
||||
|
||||
### `client/`
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `identity.js` | Persistent client seed |
|
||||
| `connection.js` | Single peer RPC client |
|
||||
| `manager.js` | Multi-peer + reconnect |
|
||||
| `errors.js` | Error normalization |
|
||||
| `index.js` | Public re-exports |
|
||||
|
||||
### Desktop shell
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `index.js` | Pear Runtime + Bridge |
|
||||
| `index.html` | Titlebar (`pear-ctrl`) + layout |
|
||||
| `app.js` | UI → manager |
|
||||
| `ui/styles.css` | Drag regions + theme |
|
||||
|
||||
See [DESKTOP.md](./DESKTOP.md).
|
||||
|
||||
## What to keep vs replace
|
||||
|
||||
| Keep | Replace when productizing |
|
||||
|------|---------------------------|
|
||||
| `shared/*` wire + crypto | Method names / schema for your domain |
|
||||
| `server/rpc/session.js` | Rarely — middleware is generic |
|
||||
| `server/core/*` | Peer policy storage backend if needed |
|
||||
| `client/connection.js` + `manager.js` | UI-specific multi-peer UX |
|
||||
| Titlebar / `pear-ctrl` patterns | Visual design only — keep drag + controls |
|
||||
| `server/handlers/demo.js` + `services/room.js` | **Your product** |
|
||||
|
||||
## Related docs
|
||||
|
||||
- [PROTOCOL.md](./PROTOCOL.md)
|
||||
- [SECURITY.md](./SECURITY.md)
|
||||
- [DESKTOP.md](./DESKTOP.md)
|
||||
- [CONFIGURATION.md](./CONFIGURATION.md)
|
||||
- [EXTENDING.md](./EXTENDING.md)
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
# CI & pipelines
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
| Workflow | Trigger | Jobs |
|
||||
|----------|---------|------|
|
||||
| `.github/workflows/ci.yml` | push / PR / manual | **test** (Node 20 + 22 matrix), **lint-docs** |
|
||||
| `.github/workflows/release.yml` | `v*` tags / manual | install, test (`SKIP_INTEGRATION=1`), pack tarball + sha256, GitHub Release |
|
||||
|
||||
### CI job details (`ci.yml`)
|
||||
|
||||
**test**
|
||||
|
||||
- `actions/checkout@v4` + `setup-node` matrix `20` / `22`
|
||||
- HTTPS rewrite for GitHub git deps
|
||||
- `npm install --no-audit --no-fund`
|
||||
- `npm test` (integration enabled by default)
|
||||
- `node --check` on key entrypoints: `server/server.js`, `client/connection.js`, `app.js`, `shared/crypto-auth.js`
|
||||
- `NODE_OPTIONS=--dns-result-order=ipv4first`
|
||||
|
||||
**lint-docs**
|
||||
|
||||
- Asserts required documentation files exist (README, architecture, protocol, getting started, security, desktop, configuration, LICENSE, etc.)
|
||||
|
||||
### Release job details (`release.yml`)
|
||||
|
||||
- Node 22
|
||||
- `SKIP_INTEGRATION=1 npm test` (avoids flaky UDP on some runners)
|
||||
- Source tarball under `dist/`
|
||||
- Upload via `softprops/action-gh-release` when ref is a tag
|
||||
|
||||
## Gitea Actions
|
||||
|
||||
| Workflow | Trigger | Job |
|
||||
|----------|---------|-----|
|
||||
| `.gitea/workflows/ci.yml` | push / PR / manual | install, `npm test`, `node scripts/healthcheck.js` (liveness) |
|
||||
| `.gitea/workflows/release-rolling.yml` | **every** push to `main`/`master` + manual | test, `scripts/gitea-rolling-release.sh` → Gitea `rolling` prerelease |
|
||||
|
||||
Mirrors patterns from peardock-class forge pipelines (IPv4-first DNS, HTTPS rewrite for GitHub deps, always-on rolling release).
|
||||
|
||||
## Local parity
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm test
|
||||
node --check server/server.js
|
||||
node --check client/connection.js
|
||||
node --check app.js
|
||||
node --check shared/crypto-auth.js
|
||||
bash scripts/release.sh
|
||||
```
|
||||
|
||||
## Integration tests in CI
|
||||
|
||||
Integration spins a real HyperDHT listener. If a runner blocks UDP/DHT:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
SKIP_INTEGRATION: '1'
|
||||
```
|
||||
|
||||
Unit tests (`crypto-auth`, `protocol`, `acl`) always run. See [TESTING.md](./TESTING.md).
|
||||
|
||||
## Secrets (forge)
|
||||
|
||||
| Secret | Used by |
|
||||
|--------|---------|
|
||||
| `GITHUB_TOKEN` | GitHub Release (automatic) |
|
||||
| `RELEASE_TOKEN` | Gitea rolling release (**required** for publish) |
|
||||
| `GITEA_URL` | Forge base URL (optional; defaults to runner `GITHUB_SERVER_URL`) |
|
||||
|
||||
Do not store `SERVER_SEED` in CI unless a dedicated deploy workflow needs it.
|
||||
|
||||
## Required docs check
|
||||
|
||||
When adding documentation, update the `lint-docs` step in `.github/workflows/ci.yml` so the file is asserted present.
|
||||
|
||||
## Related
|
||||
|
||||
- [RELEASE.md](./RELEASE.md)
|
||||
- [TESTING.md](./TESTING.md)
|
||||
@@ -0,0 +1,146 @@
|
||||
# Configuration reference
|
||||
|
||||
All knobs can be set via environment variables. The server loads `.env` through `dotenv` on boot (`server/core/keys.js`). Copy `.env.example` to get started.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server identity
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SERVER_SEED` | *auto-generated* | 32-byte secret as **64 hex** chars. Root of HMAC capabilities + admin proofs. **Keep offline / mode 0600.** |
|
||||
| `SERVER_PUBLIC_KEY` | *derived* | 32-byte public key as **64 hex**. Clients dial this. Auto-synced to `.env` when seed loads. |
|
||||
| `SERVER_KEY` | — | Alias accepted for `SERVER_SEED` (legacy). Prefer `SERVER_SEED`. |
|
||||
|
||||
On first boot without a seed, the server appends both values to `.env`.
|
||||
|
||||
Treat `SERVER_SEED` like a root password. Prefer `pd1.` invites for operators.
|
||||
|
||||
---
|
||||
|
||||
## Roles & access control
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PEARDATA_DEFAULT_ROLE` | `viewer` | Baseline role for unknown peers: `viewer` \| `operator` \| `admin` |
|
||||
| `PEARDATA_ADMIN_KEYS` | empty | Comma-separated peer public keys always elevated to **admin** |
|
||||
| `PEARDATA_ALLOWLIST` | empty | If **non-empty**, only listed peer pubs (plus already-registered policy peers) may connect |
|
||||
| `PEARDATA_INSECURE_OPEN_ADMIN` | off | `1` / `true` / `yes` → every peer is admin. **Dev only.** |
|
||||
|
||||
---
|
||||
|
||||
## Runtime paths & limits
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PEARDATA_DATA_DIR` | `./data` | Peer policy JSON + `audit.log` |
|
||||
| `PEARDATA_HOME` | OS home | Root for client identity path construction (`client/identity.js`) |
|
||||
| `PEARDATA_MAX_MESSAGES` | `500` | In-memory demo room ring buffer size |
|
||||
| `PEARDATA_RATE_LIMIT_RPM` | `120` | Per-peer RPC requests per minute |
|
||||
| `PEARDATA_MAX_RECONNECT` | `20` | Client manager reconnect attempts per peer |
|
||||
|
||||
### Data directory layout
|
||||
|
||||
```
|
||||
data/
|
||||
├── peer-policy.json # registered peers, roles, revocations, spent JTIs
|
||||
└── audit.log # JSON lines for mutating RPCs + failures
|
||||
```
|
||||
|
||||
Recommended permissions: directory `0700`. Do **not** commit `data/` or `.env`.
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `LOG_LEVEL` | `info` | `debug` \| `info` \| `warn` \| `error` |
|
||||
| `LOG_JSON` | off | `1` → structured JSON logs (good for journald) |
|
||||
|
||||
---
|
||||
|
||||
## Healthcheck & soak
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PEARDATA_HEALTH_KEY` | — | Public key for remote health dial (falls back to `SERVER_PUBLIC_KEY`) |
|
||||
| `HEALTHCHECK_TIMEOUT_MS` | `8000` | Healthcheck hard timeout (ms) |
|
||||
| `SOAK_DURATION_MS` | `60000` | Soak test run length |
|
||||
| `SOAK_INTERVAL_MS` | `500` | Delay between soak posts |
|
||||
|
||||
```bash
|
||||
# Liveness only (no dial) when no key is set
|
||||
npm run healthcheck
|
||||
|
||||
# Full dial + ping (server must be running)
|
||||
SERVER_PUBLIC_KEY=… SERVER_SEED=… npm run healthcheck
|
||||
|
||||
# Load soak (admin seed recommended so postMessage is allowed)
|
||||
SERVER_PUBLIC_KEY=… SERVER_SEED=… npm run soak
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SKIP_INTEGRATION` | unset | `1` skips live HyperDHT integration test |
|
||||
|
||||
---
|
||||
|
||||
## Pear GUI (package.json)
|
||||
|
||||
Not environment variables — set under `package.json` → `pear.gui`. See [DESKTOP.md](./DESKTOP.md).
|
||||
|
||||
| Field | Template |
|
||||
|-------|----------|
|
||||
| `width` × `height` | 1100 × 780 |
|
||||
| `minWidth` × `minHeight` | 720 × 480 |
|
||||
| `resizable` / `movable` | `true` |
|
||||
| `backgroundColor` | `#0b1020` |
|
||||
|
||||
---
|
||||
|
||||
## systemd
|
||||
|
||||
`deploy/peardata.service` expects:
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `/opt/peardata` | WorkingDirectory |
|
||||
| `/opt/peardata/.env` | `EnvironmentFile` |
|
||||
| `/opt/peardata/data` | Writable data dir |
|
||||
|
||||
Edit unit paths before enabling. See [GETTING-STARTED.md](./GETTING-STARTED.md#systemd).
|
||||
|
||||
---
|
||||
|
||||
## npm scripts
|
||||
|
||||
| Script | Command | Purpose |
|
||||
|--------|---------|---------|
|
||||
| `npm start` / `npm run dev` | `pear run -d .` | Pear desktop UI |
|
||||
| `npm run start:server` / `server` | `node server/server.js` | P2P server |
|
||||
| `npm run start:server:bin` | `node bin/peardata-server.mjs` | Alternate server entry |
|
||||
| `npm test` | brittle suite | Unit + integration |
|
||||
| `npm run test:integration` | integration only | Live DHT test |
|
||||
| `npm run healthcheck` | dial or liveness | Process / network check |
|
||||
| `npm run soak` | long connect loop | Stability exercise |
|
||||
| `npm run mint-invite -- [role] [ttlMs]` | mint `pd1.` invite | Offline invite tooling |
|
||||
| `npm run rename -- <slug> <Product>` | rebrand tree | New product from template |
|
||||
| `bash scripts/release.sh` | source tarball | Local release artifacts |
|
||||
|
||||
---
|
||||
|
||||
## Security notes
|
||||
|
||||
- Never ship `SERVER_SEED` in client bundles or public repos.
|
||||
- Prefer invites over seed distribution.
|
||||
- Rotate seed = new public key → all clients re-dial and re-invite.
|
||||
- See [SECURITY.md](./SECURITY.md) for the full checklist.
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
# Desktop (Pear) UI
|
||||
|
||||
The client shell is a **Pear desktop application** built with `pear-electron` + `pear-bridge`. The HTML UI runs inside a frameless-style window with in-content chrome.
|
||||
|
||||
## Entrypoints
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `index.js` | Pear process entry — starts `pear-electron` Runtime + `pear-bridge` |
|
||||
| `index.html` | GUI main (`pear.gui.main`) — titlebar + panels |
|
||||
| `app.js` | UI logic (connect, room, presence, invites) |
|
||||
| `ui/styles.css` | Layout, theme, **titlebar drag regions** |
|
||||
| `client/*` | HyperDHT connection stack used by the UI |
|
||||
|
||||
```bash
|
||||
npm start # pear run -d .
|
||||
npm run dev # same
|
||||
pear run -d . # equivalent
|
||||
```
|
||||
|
||||
Requires the [Pear](https://docs.pears.com) CLI installed and bootstrapped (`pear` once to fetch the runtime).
|
||||
|
||||
## Window configuration (`package.json` → `pear.gui`)
|
||||
|
||||
| Field | Template default | Purpose |
|
||||
|-------|------------------|---------|
|
||||
| `main` | `index.html` | HTML entry |
|
||||
| `width` / `height` | `1100` / `780` | Initial size |
|
||||
| `minWidth` / `minHeight` | `720` / `480` | Resize floor |
|
||||
| `resizable` | `true` | Edge/corner resize |
|
||||
| `movable` | `true` | Allow OS move (with drag region) |
|
||||
| `minimizable` / `maximizable` / `closable` | `true` | Window buttons |
|
||||
| `hasShadow` | `true` | Native shadow |
|
||||
| `backgroundColor` | `#0b1020` | Avoid white flash on boot |
|
||||
| `pre` | `pear-electron/pre` | Runtime bootstrap (required) |
|
||||
|
||||
Platform overrides are supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"pear": {
|
||||
"gui": {
|
||||
"darwin": { "resizable": true },
|
||||
"linux": { "autoHideMenuBar": true },
|
||||
"win32": { "autoHideMenuBar": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See [pear-electron README](https://github.com/holepunchto/pear-electron) for the full option list (`center`, `alwaysOnTop`, `transparent`, `closeHides`, etc.).
|
||||
|
||||
## Titlebar + `<pear-ctrl>`
|
||||
|
||||
Pear provides a custom element **`<pear-ctrl>`** for platform window controls:
|
||||
|
||||
- **macOS (`darwin`)** — layout for system traffic lights (hidden title bar chrome)
|
||||
- **Windows / Linux** — minimize, maximize, close controls rendered by the runtime
|
||||
|
||||
### Required HTML shape
|
||||
|
||||
```html
|
||||
<div id="titlebar" role="banner">
|
||||
<div class="titlebar-left">
|
||||
<pear-ctrl></pear-ctrl>
|
||||
<div class="app-brand">…</div>
|
||||
</div>
|
||||
<div class="titlebar-right">
|
||||
<!-- status chips, non-drag interactive bits -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Do **not** remove `<pear-ctrl>` unless you intentionally ship a different window frame model and understand OS differences.
|
||||
|
||||
### Drag to move
|
||||
|
||||
```css
|
||||
#titlebar {
|
||||
-webkit-app-region: drag;
|
||||
height: var(--titlebar-h); /* 42px in this template */
|
||||
}
|
||||
|
||||
/* Interactive controls must not start a drag */
|
||||
#titlebar pear-ctrl,
|
||||
#titlebar .chip,
|
||||
#titlebar button,
|
||||
#titlebar input,
|
||||
#titlebar a {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
```
|
||||
|
||||
### Resize
|
||||
|
||||
- Enabled by `pear.gui.resizable: true` (default in this template).
|
||||
- Users resize via the OS window edges/corners.
|
||||
- Content layout should flex with the viewport (`#app` uses `height: calc(100vh - var(--titlebar-h))`).
|
||||
|
||||
### Platform spacing
|
||||
|
||||
Reserve space so brand/text never sits under traffic lights:
|
||||
|
||||
```css
|
||||
#titlebar pear-ctrl[data-platform='darwin'] { min-width: 78px; }
|
||||
#titlebar pear-ctrl[data-platform='win32'],
|
||||
#titlebar pear-ctrl[data-platform='linux'] { min-width: 110px; }
|
||||
```
|
||||
|
||||
## Client identity (desktop process)
|
||||
|
||||
When the UI dials servers it uses a **persistent Ed25519 identity**:
|
||||
|
||||
| Item | Value |
|
||||
|------|--------|
|
||||
| Path | `~/.config/peardata/identity.json` (or `$PEARDATA_HOME/.config/peardata/…` if you set `PEARDATA_HOME` as home root — see `client/identity.js`) |
|
||||
| Mode | Directory `0700`, file `0600` |
|
||||
| Contents | `{ version, seedHex, createdAt }` |
|
||||
|
||||
Same identity ⇒ same `peerId` across restarts (useful for peer-bound capabilities and revoke).
|
||||
|
||||
Override home with `PEARDATA_HOME` if you need isolation (CI, multi-profile).
|
||||
|
||||
## UI modules
|
||||
|
||||
| Module | Responsibility |
|
||||
|--------|----------------|
|
||||
| `client/identity.js` | Load/create keypair on disk |
|
||||
| `client/connection.js` | Single HyperDHT + protomux-rpc session |
|
||||
| `client/manager.js` | Multi-peer map, active selection, reconnect |
|
||||
| `client/errors.js` | Unwrap / normalize RPC errors for UI |
|
||||
| `app.js` | Wire DOM to manager + protocol methods |
|
||||
|
||||
### Manager reconnect
|
||||
|
||||
- Default max tries: `PEARDATA_MAX_RECONNECT` or **20**
|
||||
- `connect(input, { adminSeed, autoReconnect })`
|
||||
- Input may be **64-hex public key** or **`pd1.` invite**
|
||||
|
||||
## LocalStorage keys (demo UI)
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `peardata:last-connect` | Last public key / invite string |
|
||||
| `peardata:display-name` | Last display name |
|
||||
|
||||
These are demo convenience only — production apps often prefer a file under app storage.
|
||||
|
||||
## Development tips
|
||||
|
||||
1. Keep DevTools available via `pear run -d .`
|
||||
2. After HTML/CSS/JS edits, reload the Pear window (or restart `npm start`)
|
||||
3. Server changes require restarting `npm run start:server`
|
||||
4. If the window cannot be moved: check `#titlebar` has `drag` and children that cover the bar incorrectly are not all `no-drag` without a parent drag region
|
||||
5. If controls don’t work: ensure `<pear-ctrl>` is present and not covered by another element with a higher z-index and full-width hit target
|
||||
|
||||
## Packaging beyond Pear
|
||||
|
||||
This template ships the **Pear run** path only. For Electron-forge / multi-arch standalone binaries, adapt packaging from a fuller product (e.g. peardock’s forge + bare-standalone scripts) once the app stabilizes.
|
||||
|
||||
## Related
|
||||
|
||||
- [GETTING-STARTED.md](./GETTING-STARTED.md)
|
||||
- [ARCHITECTURE.md](./ARCHITECTURE.md)
|
||||
- [CONFIGURATION.md](./CONFIGURATION.md)
|
||||
- [EXTENDING.md](./EXTENDING.md)
|
||||
@@ -0,0 +1,159 @@
|
||||
# Extending the template
|
||||
|
||||
## 1. Rebrand
|
||||
|
||||
```bash
|
||||
npm run rename -- notes-mesh NotesMesh
|
||||
```
|
||||
|
||||
This rewrites:
|
||||
|
||||
- package name / product name
|
||||
- protocol id (`notes-mesh/rpc`)
|
||||
- env prefix (`NOTES_MESH_`)
|
||||
- invite prefix (derived, e.g. `no1.`)
|
||||
- binary + systemd unit filenames
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm test
|
||||
git diff # review
|
||||
```
|
||||
|
||||
## 1b. Titlebar / window chrome
|
||||
|
||||
Keep these when restyling:
|
||||
|
||||
| Piece | Role |
|
||||
|-------|------|
|
||||
| `<pear-ctrl>` in `#titlebar` | Close / minimize / maximize (Pear runtime custom element) |
|
||||
| `#titlebar { -webkit-app-region: drag }` | Drag the window |
|
||||
| Interactive children `no-drag` | Buttons, chips, inputs stay clickable |
|
||||
| `pear.gui.resizable: true` | Edge/corner resize |
|
||||
| `pear.gui.minWidth` / `minHeight` | Floor size while resizing |
|
||||
|
||||
Do not remove `<pear-ctrl>` unless you intentionally want a frame without in-content controls (and understand platform differences). Details: [DESKTOP.md](./DESKTOP.md).
|
||||
|
||||
## 2. Add an RPC method
|
||||
|
||||
### `shared/protocol.js`
|
||||
|
||||
```js
|
||||
export const MethodRoles = Object.freeze({
|
||||
// ...
|
||||
listNotes: Roles.viewer,
|
||||
createNote: Roles.operator,
|
||||
})
|
||||
```
|
||||
|
||||
`Methods` is derived automatically from `MethodRoles` keys.
|
||||
|
||||
### `shared/schema.js`
|
||||
|
||||
Validate `createNote` args (required fields, max lengths).
|
||||
|
||||
### `server/services/notes.js`
|
||||
|
||||
Domain logic / storage (keep IO out of handlers when possible).
|
||||
|
||||
### `server/handlers/notes.js`
|
||||
|
||||
```js
|
||||
export function registerNoteHandlers(session) {
|
||||
session.respond('listNotes', async () => ({ notes: [] }))
|
||||
session.respond('createNote', async (args, s) => { /* ... */ })
|
||||
}
|
||||
```
|
||||
|
||||
### `server/rpc/register.js`
|
||||
|
||||
Call `registerNoteHandlers(session)`.
|
||||
|
||||
### Client / UI
|
||||
|
||||
```js
|
||||
await manager.request(Methods.createNote, { title: '…' })
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
- Unit for pure helpers + schema
|
||||
- Integration for the happy path if AuthZ/wire matter
|
||||
|
||||
## 3. Add a push channel
|
||||
|
||||
1. Add to `Pushes` in `shared/protocol.js`
|
||||
2. Optionally map in `PushToType`
|
||||
3. `session.push(Pushes.foo, payload)` or broadcast via peer registry
|
||||
4. Listen in UI: `manager.on('push', …)` or `conn.on(Pushes.foo, …)`
|
||||
(`connection.js` auto-registers all `Pushes` values)
|
||||
|
||||
## 4. Persistence
|
||||
|
||||
Demo room is in-memory (`server/services/room.js`). Swap for:
|
||||
|
||||
| Store | Good for |
|
||||
|-------|----------|
|
||||
| **Corestore / Hypercore** | Append-only logs, P2P replication |
|
||||
| **SQLite** | Structured queries |
|
||||
| **JSON files** under `PEARDATA_DATA_DIR` | Small config (peer-policy already does this) |
|
||||
|
||||
Keep RPC handlers thin; put IO in `services/`.
|
||||
|
||||
## 5. Binary streams
|
||||
|
||||
Peardock-class apps use chunked binary RPC for uploads. Pattern:
|
||||
|
||||
- methods `binaryStreamOpen` / `Chunk` / `Close` marked `hot: true` in `session.respond`
|
||||
- skip heavy schema/audit on the hot path
|
||||
- still enforce ACL + rate limits
|
||||
|
||||
Stub hooks exist via `rateLimiter.isStreamMethod`.
|
||||
|
||||
## 6. Multi-server fleet
|
||||
|
||||
`client/manager.js` already holds many connections + active selection + reconnect:
|
||||
|
||||
```js
|
||||
await manager.connect(keyOrInviteA, { autoReconnect: true })
|
||||
await manager.connect(keyOrInviteB, { autoReconnect: true })
|
||||
manager.setActive(keyA)
|
||||
await manager.request(Methods.ping, {})
|
||||
```
|
||||
|
||||
Point UI at a peer list stored in `localStorage` or a file cache for a fuller fleet UX.
|
||||
|
||||
## 7. Custom desktop UX
|
||||
|
||||
| Goal | Touch |
|
||||
|------|-------|
|
||||
| New screens | `index.html` + `app.js` + `ui/styles.css` |
|
||||
| Window size | `package.json` → `pear.gui` |
|
||||
| Branding in titlebar | `.app-brand` markup/CSS |
|
||||
| Persist UI prefs | replace demo `localStorage` keys |
|
||||
|
||||
Preserve drag / `pear-ctrl` behavior — [DESKTOP.md](./DESKTOP.md).
|
||||
|
||||
## 8. Desktop packaging
|
||||
|
||||
This template ships the **Pear** GUI path (`pear run`). For Electron-forge / bare-standalone multi-arch releases, copy more complex packaging scripts from a production app (forge config, make scripts) once your protocol stabilizes.
|
||||
|
||||
## 9. Checklist for a new product
|
||||
|
||||
- [ ] `npm run rename -- …`
|
||||
- [ ] Replace demo handlers + services
|
||||
- [ ] Update PROTOCOL.md method table
|
||||
- [ ] Update SECURITY / CONFIG if new secrets
|
||||
- [ ] Tests green (`npm test`)
|
||||
- [ ] README product description
|
||||
- [ ] CI still green
|
||||
- [ ] First release tag ([RELEASE.md](./RELEASE.md))
|
||||
|
||||
## Related
|
||||
|
||||
- [ARCHITECTURE.md](./ARCHITECTURE.md)
|
||||
- [PROTOCOL.md](./PROTOCOL.md)
|
||||
- [TESTING.md](./TESTING.md)
|
||||
- [DESKTOP.md](./DESKTOP.md)
|
||||
@@ -0,0 +1,188 @@
|
||||
# Getting started
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Required | Notes |
|
||||
|------|----------|--------|
|
||||
| **Node.js ≥ 20** | Yes | Server + tests |
|
||||
| **npm** | Yes | Install deps |
|
||||
| **[Pear](https://docs.pears.com) CLI** | For desktop UI | Run `pear` once to bootstrap the runtime |
|
||||
| UDP / network | For real peers | HyperDHT hole-punching |
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone <your-fork-or-template-url> my-app
|
||||
cd my-app
|
||||
npm install
|
||||
cp .env.example .env # optional; server auto-writes seed on first boot
|
||||
```
|
||||
|
||||
## Run the server
|
||||
|
||||
```bash
|
||||
npm run start:server
|
||||
# alias: npm run server
|
||||
```
|
||||
|
||||
On first boot the server appends to `.env`:
|
||||
|
||||
```
|
||||
SERVER_SEED=<64 hex secret>
|
||||
SERVER_PUBLIC_KEY=<64 hex public>
|
||||
```
|
||||
|
||||
**Treat `SERVER_SEED` like a root password.** Anyone with it can mint admin proofs and capabilities.
|
||||
|
||||
Banner output shows the public key clients dial, for example:
|
||||
|
||||
```
|
||||
Client → dial <SERVER_PUBLIC_KEY>
|
||||
```
|
||||
|
||||
Leave this process running while clients connect.
|
||||
|
||||
## Run the desktop UI
|
||||
|
||||
```bash
|
||||
npm start
|
||||
# or: npm run dev
|
||||
# or: pear run -d .
|
||||
```
|
||||
|
||||
### Desktop window chrome
|
||||
|
||||
The Pear UI uses a custom titlebar:
|
||||
|
||||
| Piece | Behavior |
|
||||
|-------|----------|
|
||||
| `<pear-ctrl>` | Platform window controls (macOS traffic lights / Windows & Linux min·max·close) |
|
||||
| `#titlebar` | `-webkit-app-region: drag` — drag to move the window |
|
||||
| Interactive children | `no-drag` so buttons and chips stay clickable |
|
||||
| `pear.gui.resizable` | Edge/corner resize (`true` by default) |
|
||||
| `minWidth` / `minHeight` | 720 × 480 floor |
|
||||
|
||||
Full details: [DESKTOP.md](./DESKTOP.md).
|
||||
|
||||
## Connect as admin (dev)
|
||||
|
||||
1. Start the Pear UI: `npm start`
|
||||
2. Paste `SERVER_PUBLIC_KEY` into **Server / invite**
|
||||
3. Paste `SERVER_SEED` into **Admin seed**
|
||||
4. Optionally set a **Display name**
|
||||
5. Click **Connect** → role badge should show `admin`
|
||||
|
||||
The seed never goes over the wire as plaintext — the client sends an HMAC **admin proof**.
|
||||
|
||||
## Connect via invite
|
||||
|
||||
```bash
|
||||
# Persistent operator invite (default)
|
||||
npm run mint-invite -- operator
|
||||
|
||||
# 7-day operator invite (ttl in ms)
|
||||
npm run mint-invite -- operator 604800000
|
||||
|
||||
# Admin invite
|
||||
npm run mint-invite -- admin
|
||||
```
|
||||
|
||||
Stdout prints a `pd1.…` string. Paste it into the UI connect field (no seed needed).
|
||||
|
||||
You can also mint from a connected admin session with **Mint invite** in the UI.
|
||||
|
||||
## Viewer-only
|
||||
|
||||
Paste only the public key. You can:
|
||||
|
||||
- `listMessages`, `getPresence`, `getServerInfo`, `setDisplayName`, `ping`
|
||||
|
||||
You cannot:
|
||||
|
||||
- `postMessage` (needs `operator+`)
|
||||
- `clearMessages`, `mintInvite`, `listPeers`, `revokePeer` (needs `admin`)
|
||||
|
||||
Unless you raise `PEARDATA_DEFAULT_ROLE` (not recommended for multi-user hosts).
|
||||
|
||||
## Quick verification
|
||||
|
||||
```bash
|
||||
npm test
|
||||
SKIP_INTEGRATION=1 npm test # unit only
|
||||
|
||||
# With server running:
|
||||
export SERVER_PUBLIC_KEY=… # from .env
|
||||
export SERVER_SEED=… # optional but enables admin dial
|
||||
npm run healthcheck
|
||||
npm run soak # optional load exercise
|
||||
```
|
||||
|
||||
## Environment knobs (summary)
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `SERVER_SEED` / `SERVER_PUBLIC_KEY` | Server identity |
|
||||
| `PEARDATA_DEFAULT_ROLE` | Baseline role (`viewer` default) |
|
||||
| `PEARDATA_ADMIN_KEYS` | Peer pubs always admin |
|
||||
| `PEARDATA_INSECURE_OPEN_ADMIN` | Dev only — all peers admin |
|
||||
| `PEARDATA_ALLOWLIST` | If set, only listed / registered peers |
|
||||
| `PEARDATA_DATA_DIR` | Peer policy + audit log directory |
|
||||
| `PEARDATA_RATE_LIMIT_RPM` | Per-peer RPC budget |
|
||||
| `PEARDATA_MAX_MESSAGES` | Demo room history cap |
|
||||
| `PEARDATA_MAX_RECONNECT` | Client reconnect tries |
|
||||
| `LOG_LEVEL` / `LOG_JSON` | Logging |
|
||||
|
||||
Full table: [CONFIGURATION.md](./CONFIGURATION.md).
|
||||
|
||||
## Rebrand for a new product
|
||||
|
||||
```bash
|
||||
npm run rename -- my-app MyApp
|
||||
npm install
|
||||
npm test
|
||||
```
|
||||
|
||||
Rewrites package name, protocol id, env prefixes, invite prefix (`pd1.` → derived), product strings, and renames the server binary / systemd unit. Review `git diff` after.
|
||||
|
||||
## systemd
|
||||
|
||||
```bash
|
||||
# Install tree to /opt/peardata (example)
|
||||
sudo mkdir -p /opt/peardata
|
||||
sudo rsync -a --exclude node_modules --exclude .git ./ /opt/peardata/
|
||||
cd /opt/peardata && sudo npm install --omit=dev
|
||||
|
||||
sudo cp deploy/peardata.service /etc/systemd/system/
|
||||
# Edit WorkingDirectory, EnvironmentFile, ReadWritePaths if paths differ
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now peardata
|
||||
sudo journalctl -u peardata -f
|
||||
```
|
||||
|
||||
The unit sets `NoNewPrivileges`, `ProtectSystem=strict`, and writable paths for `data/` + `.env`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | What to check |
|
||||
|---------|----------------|
|
||||
| `Connection timeout` | Server running? Correct 64-hex key? Firewall / UDP? |
|
||||
| `PERMISSION_DENIED` on send | Role is viewer — use invite or admin seed |
|
||||
| `Rate limit exceeded` | Raise `PEARDATA_RATE_LIMIT_RPM` or slow clients |
|
||||
| Window won’t drag | Titlebar drag CSS; don’t cover bar with full-screen `no-drag` overlay |
|
||||
| Window won’t resize | `pear.gui.resizable` must be true; try edges not just corners |
|
||||
| No `<pear-ctrl>` buttons | Running under Pear (`pear run`)? Element is runtime-provided |
|
||||
| Integration test fails in CI | Set `SKIP_INTEGRATION=1` or allow UDP |
|
||||
| Seed regenerated every boot | `.env` not writable / wrong cwd |
|
||||
|
||||
## Next steps
|
||||
|
||||
| Doc | When |
|
||||
|-----|------|
|
||||
| [DESKTOP.md](./DESKTOP.md) | Titlebar, pear-ctrl, packaging |
|
||||
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Stack & session pipeline |
|
||||
| [PROTOCOL.md](./PROTOCOL.md) | Methods, pushes, versioning |
|
||||
| [SECURITY.md](./SECURITY.md) | Production hardening |
|
||||
| [CONFIGURATION.md](./CONFIGURATION.md) | Full env reference |
|
||||
| [EXTENDING.md](./EXTENDING.md) | Replace the demo room |
|
||||
| [TESTING.md](./TESTING.md) | Tests & soak |
|
||||
| [CI.md](./CI.md) / [RELEASE.md](./RELEASE.md) | Pipelines & shipping |
|
||||
@@ -0,0 +1,158 @@
|
||||
# Protocol
|
||||
|
||||
## Constants
|
||||
|
||||
| Constant | Value |
|
||||
|----------|--------|
|
||||
| `PROTOCOL` | `peardata/rpc` |
|
||||
| `PROTOCOL_VERSION` | `1` |
|
||||
| `APP_NAME` | `peardata` |
|
||||
| `APP_VERSION` | `0.1.0` (keep in sync with package where useful) |
|
||||
| Encoding | compact-encoding JSON (`shared/encodings.js`) |
|
||||
| Schema | lightweight validators (`shared/schema.js`) `SCHEMA_VERSION=1` |
|
||||
|
||||
Bump `PROTOCOL_VERSION` on breaking request/response shapes. Additive methods may land without a bump if clients ignore unknown methods.
|
||||
|
||||
After `npm run rename`, `PROTOCOL` becomes `<slug>/rpc` and invite prefix is regenerated.
|
||||
|
||||
## Transport
|
||||
|
||||
1. Client opens HyperDHT secret stream to server public key (Noise, mutual key auth).
|
||||
2. `ProtomuxRPC` is attached with `protocol: PROTOCOL` and shared encodings.
|
||||
3. Client calls `handshake` before other RPCs (connection helper does this automatically).
|
||||
4. Server may `push` events on named channels.
|
||||
|
||||
## Roles
|
||||
|
||||
| Role | Intent |
|
||||
|------|--------|
|
||||
| `viewer` | Read-only |
|
||||
| `operator` | Mutate domain data |
|
||||
| `admin` | Invite mint, clear, revoke, config |
|
||||
|
||||
Hierarchy: `admin > operator > viewer` (`roleAllows`).
|
||||
|
||||
Unknown methods default to **admin** required in `assertAllowed` if missing from `MethodRoles` — always register new methods.
|
||||
|
||||
## Methods
|
||||
|
||||
| Method | Min role | Request args | Response (summary) |
|
||||
|--------|----------|--------------|--------------------|
|
||||
| `handshake` | viewer | `clientName`, `clientVersion`, optional `capability`, `adminProof` | role, auth, versions, features |
|
||||
| `ping` | viewer | `{}` | `{ ok, pong, peerId }` |
|
||||
| `getServerInfo` | viewer | `{}` | app, versions, host, peer count |
|
||||
| `getAuthStatus` | viewer | `{}` | peerId, role, authMode, displayName |
|
||||
| `listMessages` | viewer | `{ limit? }` | `{ messages: [...] }` |
|
||||
| `getPresence` | viewer | `{}` | `{ peers: [...] }` |
|
||||
| `postMessage` | operator | `{ text }` (1–2000 chars) | created message |
|
||||
| `setDisplayName` | viewer | `{ name }` (1–40 chars) | updated label |
|
||||
| `clearMessages` | admin | `{}` | success + system push |
|
||||
| `mintInvite` | admin | `{ role?, ttlMs?, peerId?, alias? }` | `invite` (`pd1.…`), jti, exp |
|
||||
| `listPeers` | admin | `{}` | live + policy peers |
|
||||
| `revokePeer` | admin | `{ peerId }` (64 hex) | success; target dropped |
|
||||
|
||||
### Handshake request
|
||||
|
||||
```json
|
||||
{
|
||||
"clientName": "peardata",
|
||||
"clientVersion": "0.1.0",
|
||||
"capability": "<optional HMAC token>",
|
||||
"adminProof": { "nonce": "<hex>", "mac": "<hex>" }
|
||||
}
|
||||
```
|
||||
|
||||
### Handshake response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"protocol": "peardata/rpc",
|
||||
"protocolVersion": 1,
|
||||
"schemaVersion": 1,
|
||||
"role": "operator",
|
||||
"peerId": "<64 hex>",
|
||||
"serverTime": 0,
|
||||
"auth": { "mode": "capability", "role": "operator" },
|
||||
"features": { "hmacAuth": true, "invites": true, "room": true }
|
||||
}
|
||||
```
|
||||
|
||||
### Auth modes (`auth.mode` / `session.authMode`)
|
||||
|
||||
| Mode | How obtained |
|
||||
|------|----------------|
|
||||
| `viewer` | Default after connect with no grant |
|
||||
| `capability` | Valid capability / invite |
|
||||
| `seed` | Valid admin proof from `SERVER_SEED` |
|
||||
| `allowlist` / registered | Elevated via policy / admin keys (implementation in ACL + policy) |
|
||||
|
||||
Exact labels depend on handshake path; UI shows `authMode` from `getAuthStatus`.
|
||||
|
||||
## Pushes (server → client events)
|
||||
|
||||
| Push | Payload |
|
||||
|------|---------|
|
||||
| `push:message` | `{ id, peerId, displayName, text, ts }` |
|
||||
| `push:presence` | `{ peers: [...] }` |
|
||||
| `push:system` | `{ type, ... }` e.g. `{ type: "cleared" }` |
|
||||
|
||||
Registered via `rpc.event` / `session.push`. Client `connection.js` binds all `Pushes` values.
|
||||
|
||||
## Invites
|
||||
|
||||
Envelope: `pd1.` + base64url(JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"v": 1,
|
||||
"publicKeyHex": "<server>",
|
||||
"capability": "<token>",
|
||||
"role": "operator",
|
||||
"jti": "...",
|
||||
"expiresAt": null
|
||||
}
|
||||
```
|
||||
|
||||
Capability token: `base64url(payload).base64url(HMAC-SHA256)`.
|
||||
|
||||
Payload fields (canonical order for MAC): `v`, `role`, `peerId`, `exp`, `jti`, `iat`.
|
||||
|
||||
### Connection input classification
|
||||
|
||||
`classifyConnectionInput(string)` accepts:
|
||||
|
||||
| Input | Kind |
|
||||
|-------|------|
|
||||
| 64 hex chars | `publicKey` |
|
||||
| `pd1.…` | `invite` (extracts key + capability) |
|
||||
| other | error |
|
||||
|
||||
## Error codes
|
||||
|
||||
| Code | When |
|
||||
|------|------|
|
||||
| `RATE_LIMIT_EXCEEDED` | Peer over RPM budget |
|
||||
| `PERMISSION_DENIED` | Role too low for method |
|
||||
| `INVALID_ARGS` | Schema validation failed |
|
||||
| `CONNECTION_TIMEOUT` | Client dial timeout |
|
||||
| `RPC_ERROR` | Generic client-normalized failure |
|
||||
| `UNKNOWN_ERROR` | Unclassified server handler error |
|
||||
|
||||
Clients should read `error.code` when present (`client/errors.js` preserves codes).
|
||||
|
||||
## Versioning policy
|
||||
|
||||
1. Document every method in this file.
|
||||
2. Add `MethodRoles` entry before implementing handlers.
|
||||
3. Add `validateMethodArgs` case for mutating methods.
|
||||
4. Add brittle tests for pure helpers; integration test for critical paths.
|
||||
5. Bump `PROTOCOL_VERSION` when existing response shapes break.
|
||||
6. Bump `SCHEMA_VERSION` when validation semantics change meaningfully.
|
||||
|
||||
## Related
|
||||
|
||||
- [ARCHITECTURE.md](./ARCHITECTURE.md)
|
||||
- [SECURITY.md](./SECURITY.md)
|
||||
- [EXTENDING.md](./EXTENDING.md)
|
||||
- [TESTING.md](./TESTING.md)
|
||||
@@ -0,0 +1,18 @@
|
||||
# Documentation index
|
||||
|
||||
| Doc | Audience | Contents |
|
||||
|-----|----------|----------|
|
||||
| [GETTING-STARTED.md](./GETTING-STARTED.md) | New operators | Install, run, connect, systemd, troubleshooting |
|
||||
| [DESKTOP.md](./DESKTOP.md) | UI developers | Pear shell, `pear-ctrl`, drag/resize, identity |
|
||||
| [ARCHITECTURE.md](./ARCHITECTURE.md) | Engineers | Planes, boot, middleware, module map |
|
||||
| [PROTOCOL.md](./PROTOCOL.md) | Protocol owners | Methods, pushes, errors, versioning |
|
||||
| [SECURITY.md](./SECURITY.md) | Operators / security | Trust model, checklist, crypto, incidents |
|
||||
| [CONFIGURATION.md](./CONFIGURATION.md) | Operators | Full env vars + npm scripts |
|
||||
| [TESTING.md](./TESTING.md) | Contributors | brittle suite, soak, manual QA |
|
||||
| [CI.md](./CI.md) | Maintainers | GitHub/Gitea pipelines |
|
||||
| [RELEASE.md](./RELEASE.md) | Maintainers | Version, tag, tarball, rollback |
|
||||
| [EXTENDING.md](./EXTENDING.md) | Product builders | Rebrand, new RPCs, persistence, fleet |
|
||||
|
||||
Start here if you are new: **[GETTING-STARTED.md](./GETTING-STARTED.md)**.
|
||||
|
||||
Root overview: **[../README.md](../README.md)**.
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
# Release process
|
||||
|
||||
## Preconditions
|
||||
|
||||
- [ ] `npm test` passes (use `SKIP_INTEGRATION=1` only if the runner cannot do DHT)
|
||||
- [ ] Version bumped in `package.json`
|
||||
- [ ] Docs updated if protocol / env / UX changed
|
||||
- [ ] No secrets in the tree (`.env`, `data/`, identity files)
|
||||
- [ ] `git status` clean except intended changes
|
||||
|
||||
## Version & tag
|
||||
|
||||
```bash
|
||||
# 1. Bump version in package.json (semver)
|
||||
# 2. Commit
|
||||
git add package.json
|
||||
git commit -m "Release vX.Y.Z"
|
||||
|
||||
# 3. Tag
|
||||
git tag -a vX.Y.Z -m "vX.Y.Z"
|
||||
|
||||
# 4. Push
|
||||
git push origin main
|
||||
git push origin vX.Y.Z
|
||||
```
|
||||
|
||||
Tag pattern **`v*`** triggers versioned GitHub releases. On Gitea, **every push to `main`/`master`** rebuilds the rolling release:
|
||||
|
||||
| Forge | Workflow | Trigger | Output |
|
||||
|-------|----------|---------|--------|
|
||||
| GitHub | `.github/workflows/release.yml` | `v*` tags / manual | Source tarball + sha256 → GitHub Release |
|
||||
| Gitea | `.gitea/workflows/release-rolling.yml` | push to `main`/`master` / manual | `scripts/gitea-rolling-release.sh` → prerelease tag **`rolling`** |
|
||||
|
||||
The rolling job always runs after a successful install+test on the default branch. It deletes and recreates the `rolling` tag/release so operators can always download the latest main build.
|
||||
|
||||
## Local artifacts
|
||||
|
||||
```bash
|
||||
bash scripts/release.sh
|
||||
```
|
||||
|
||||
Produces:
|
||||
|
||||
```
|
||||
dist/
|
||||
├── peardata-vX.Y.Z.tar.gz
|
||||
├── peardata-vX.Y.Z.tar.gz.sha256
|
||||
└── RELEASE_NOTES.md
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
cd dist
|
||||
sha256sum -c peardata-vX.Y.Z.tar.gz.sha256
|
||||
# or: shasum -a 256 -c …
|
||||
```
|
||||
|
||||
Tarball **excludes** `node_modules/`, `.git/`, `data/`, `dist/`.
|
||||
|
||||
## What ships
|
||||
|
||||
| Included | Not included |
|
||||
|----------|--------------|
|
||||
| Source (server, client, shared, UI) | `node_modules` |
|
||||
| CI workflows | `.env` / secrets |
|
||||
| Docs + systemd unit | Runtime `data/` |
|
||||
| Scripts | Generated release dist |
|
||||
|
||||
Consumers install with `npm install` after unpacking.
|
||||
|
||||
## Changelog guidance
|
||||
|
||||
For each release note:
|
||||
|
||||
1. **Protocol** — method / push / version bumps
|
||||
2. **Security** — auth or default role changes
|
||||
3. **Desktop** — pear-ctrl / window / Pear dependency bumps
|
||||
4. **Ops** — env vars, systemd, healthcheck
|
||||
5. **Breaking** — call out re-dial / re-invite requirements
|
||||
|
||||
## Hotfix without retag
|
||||
|
||||
Push commits to `main` for CI only. Prefer a new patch tag for anything operators must download.
|
||||
|
||||
## Rollback
|
||||
|
||||
- **App code**: redeploy previous tag tarball / checkout
|
||||
- **Server seed**: only if compromised — generate new seed (new public key); re-issue all invites; clients must dial the new key
|
||||
- **Peer policy**: restore `data/peer-policy.json` from backup if revoke tables were corrupted
|
||||
|
||||
## Secrets for forges
|
||||
|
||||
| Secret | Where | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `GITHUB_TOKEN` | GitHub (automatic) | Upload release assets |
|
||||
| `RELEASE_TOKEN` | Gitea (**required**) | Publish/update `rolling` release |
|
||||
| `GITEA_URL` | Gitea (optional) | Forge API base (defaults from runner) |
|
||||
|
||||
Never put `SERVER_SEED` in CI secrets unless a dedicated deploy job needs it — prefer generating seeds on the target host.
|
||||
|
||||
## Related
|
||||
|
||||
- [CI.md](./CI.md)
|
||||
- [SECURITY.md](./SECURITY.md)
|
||||
- [CONFIGURATION.md](./CONFIGURATION.md)
|
||||
@@ -0,0 +1,96 @@
|
||||
# Security
|
||||
|
||||
## Trust model
|
||||
|
||||
- **Server seed** (`SERVER_SEED`) is the root of HMAC auth (capabilities + admin proofs). Compromise = full admin minting.
|
||||
- **Client identity** seed under `~/.config/peardata/identity.json` identifies the peer across reconnects. Protect it if you bind capabilities to `peerId`.
|
||||
- **HyperDHT** provides mutual authentication of keypairs on the secret stream. RPC still needs application AuthZ (roles).
|
||||
- **UI process** can hold an admin seed in memory when the user pastes it — treat the desktop machine as trusted for that session.
|
||||
|
||||
## Auth paths
|
||||
|
||||
1. **Admin proof** — client proves knowledge of seed-derived MAC key without sending the seed (`createAdminProof` / `verifyAdminProof`).
|
||||
2. **Capability** — server-signed grant with role, optional expiry, optional peer binding, JTI spend tracking.
|
||||
3. **Registered peer** — after a successful grant, reconnects may use the stored role without replaying a spent JTI (see `redeemCapability` / peer policy).
|
||||
4. **Admin keys env** — `PEARDATA_ADMIN_KEYS` forces admin for listed peer public keys.
|
||||
5. **Revocation** — `revokePeer` drops live sessions and blocks future dials.
|
||||
6. **Allowlist** — when `PEARDATA_ALLOWLIST` is non-empty, unknown peers are rejected.
|
||||
|
||||
## Secure defaults
|
||||
|
||||
| Default | Value |
|
||||
|---------|--------|
|
||||
| Unknown peer role | `viewer` |
|
||||
| Open admin | **off** |
|
||||
| Capability forever | yes unless `ttlMs` set |
|
||||
| Rate limit | 120 RPC / minute / peer |
|
||||
| Audit | mutating methods + handshake failures |
|
||||
| Identity file mode | `0600` |
|
||||
| Data directory | local `./data` (not committed) |
|
||||
|
||||
## Production checklist
|
||||
|
||||
- [ ] Never set `PEARDATA_INSECURE_OPEN_ADMIN` outside local demos
|
||||
- [ ] Keep `PEARDATA_DEFAULT_ROLE=viewer`
|
||||
- [ ] Prefer invites over sharing `SERVER_SEED`
|
||||
- [ ] Use short `ttlMs` for high-privilege invites when practical
|
||||
- [ ] Set `PEARDATA_ALLOWLIST` if only known operators should dial
|
||||
- [ ] Back up `SERVER_SEED` offline; rotate by redeploying a new keypair (clients must re-dial)
|
||||
- [ ] Persist `data/` with mode `0700`; `audit.log` may contain peer ids
|
||||
- [ ] Run under systemd with `ProtectSystem` / `NoNewPrivileges` (see `deploy/`)
|
||||
- [ ] Do not embed seed in frontend builds, CI logs, or crash reports
|
||||
- [ ] Review `LOG_LEVEL=debug` before production (avoid verbose auth noise)
|
||||
- [ ] Keep Pear / dependency updates current (`npm outdated`)
|
||||
|
||||
## Threat notes
|
||||
|
||||
| Threat | Mitigation |
|
||||
|--------|------------|
|
||||
| Stolen invite | Short TTL; peer-bound capabilities; revoke JTI / peer |
|
||||
| Stolen client identity | Revoke peer id; re-issue invites |
|
||||
| Stolen `SERVER_SEED` | Rotate keypair; all grants invalid; re-onboard clients |
|
||||
| RPC spam | Rate limiter (`PEARDATA_RATE_LIMIT_RPM`) |
|
||||
| Confused deputy role | Server never trusts client-supplied role field |
|
||||
| Log leakage | Logger never prints seeds/tokens |
|
||||
| Rogue desktop | OS user access = ability to paste seed; use invites on shared machines |
|
||||
| Supply chain | Pin deps; review `npm audit`; CI from trusted runners |
|
||||
|
||||
## Crypto details
|
||||
|
||||
| Item | Algorithm / format |
|
||||
|------|---------------------|
|
||||
| DHT identity | Ed25519 via HyperDHT keyPair(seed) |
|
||||
| MAC key | HKDF-SHA256(seed, salt=`peardata-hmac-v1`, info=`capability`) → 32 bytes |
|
||||
| Capability MAC | HMAC-SHA256(macKey, canonical JSON payload) |
|
||||
| Admin proof | HMAC-SHA256(macKey, `peardata-admin-v1` ‖ nonce ‖ peerId ‖ serverPk) |
|
||||
| Invite envelope | `pd1.` + base64url(JSON) |
|
||||
|
||||
Implementation: `shared/crypto-auth.js`.
|
||||
|
||||
### Canonical capability payload fields
|
||||
|
||||
`v`, `role`, `peerId`, `exp`, `jti`, `iat` — ordered JSON before MAC.
|
||||
|
||||
## Operational security
|
||||
|
||||
| Artifact | Sensitivity | Handling |
|
||||
|----------|-------------|----------|
|
||||
| `.env` | Critical | Never commit; backup offline |
|
||||
| `data/peer-policy.json` | High | Contains roles & JTIs |
|
||||
| `data/audit.log` | Medium | Peer activity metadata |
|
||||
| `identity.json` | High for that user | Per-machine client secret |
|
||||
| Release tarballs | Low | Source only; no secrets |
|
||||
|
||||
## Incident response (seed leak)
|
||||
|
||||
1. Stop accepting connections on the compromised key (shutdown / firewall).
|
||||
2. Generate new `SERVER_SEED` on a clean host (new public key).
|
||||
3. Deploy new server; do not reuse old seed.
|
||||
4. Re-issue invites to operators; notify clients of new public key.
|
||||
5. Review `audit.log` for abuse window.
|
||||
|
||||
## Related
|
||||
|
||||
- [CONFIGURATION.md](./CONFIGURATION.md)
|
||||
- [PROTOCOL.md](./PROTOCOL.md)
|
||||
- [RELEASE.md](./RELEASE.md)
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Testing
|
||||
|
||||
## Test stack
|
||||
|
||||
| Piece | Tool |
|
||||
|-------|------|
|
||||
| Runner | [brittle](https://github.com/holepunchto/brittle) via `brittle-node` |
|
||||
| Location | `test/*.test.js` |
|
||||
| Command | `npm test` |
|
||||
|
||||
## Suite map
|
||||
|
||||
| File | Coverage |
|
||||
|------|----------|
|
||||
| `test/acl.test.js` | Role hierarchy, `assertAllowed` |
|
||||
| `test/crypto-auth.test.js` | MAC key, capabilities, admin proof, invites, classify input |
|
||||
| `test/protocol.test.js` | Constants, `MethodRoles`, schema validators |
|
||||
| `test/integration.test.js` | Live HyperDHT server + client handshake, post, push |
|
||||
|
||||
```bash
|
||||
npm test
|
||||
npm run test:integration
|
||||
|
||||
# Skip live DHT (CI runners without UDP, offline laptops)
|
||||
SKIP_INTEGRATION=1 npm test
|
||||
```
|
||||
|
||||
## Integration test behavior
|
||||
|
||||
1. Starts an ephemeral HyperDHT server in-process
|
||||
2. Sets `PEARDATA_INSECURE_OPEN_ADMIN=1` for the process (restored in teardown)
|
||||
3. Dials as a client, handshakes, posts a message, asserts push delivery
|
||||
4. Tears down sockets / DHT
|
||||
|
||||
Requires outbound/inbound UDP for HyperDHT. If the test hangs or fails on a locked-down network, use `SKIP_INTEGRATION=1`.
|
||||
|
||||
## Writing tests
|
||||
|
||||
### Unit (preferred for pure logic)
|
||||
|
||||
- Put pure helpers in `shared/` or thin `server/core` modules
|
||||
- Assert without networking
|
||||
- Cover: validation failures, role denials, crypto tampering, expiry
|
||||
|
||||
Example pattern:
|
||||
|
||||
```js
|
||||
import test from 'brittle'
|
||||
import { roleAllows, Roles } from '../shared/protocol.js'
|
||||
|
||||
test('operator cannot admin-only methods', (t) => {
|
||||
t.ok(roleAllows(Roles.operator, Roles.viewer))
|
||||
t.absent(roleAllows(Roles.operator, Roles.admin))
|
||||
})
|
||||
```
|
||||
|
||||
### Integration (critical paths)
|
||||
|
||||
- Boot real `PeerSession` stack or full server when AuthZ + wire encoding matter
|
||||
- Always clean up DHT / sockets in `t.teardown`
|
||||
- Prefer one happy-path + one auth-failure path over many flaky cases
|
||||
|
||||
### Schema / protocol
|
||||
|
||||
When adding an RPC method:
|
||||
|
||||
1. `MethodRoles` entry
|
||||
2. `validateMethodArgs` case (if args matter)
|
||||
3. Unit test for validator
|
||||
4. Optional integration call for the happy path
|
||||
|
||||
## Manual checks
|
||||
|
||||
| Check | How |
|
||||
|-------|-----|
|
||||
| Server boots | `npm run start:server` — prints public key |
|
||||
| Invite mint | `npm run mint-invite -- operator` |
|
||||
| Desktop chrome | `npm start` — drag titlebar, resize edges, min/max/close |
|
||||
| Admin connect | Paste key + `SERVER_SEED` in UI |
|
||||
| Invite connect | Paste `pd1.…` without seed |
|
||||
| Viewer denial | Public key only → `postMessage` fails with permission error |
|
||||
| Health | With server up: `SERVER_PUBLIC_KEY=… npm run healthcheck` |
|
||||
| Soak | `SERVER_PUBLIC_KEY=… SERVER_SEED=… npm run soak` |
|
||||
|
||||
## Soak test
|
||||
|
||||
```bash
|
||||
# default 60s
|
||||
SERVER_PUBLIC_KEY=<hex> SERVER_SEED=<hex> npm run soak
|
||||
|
||||
SOAK_DURATION_MS=300000 SOAK_INTERVAL_MS=250 npm run soak
|
||||
```
|
||||
|
||||
Reports sent / received / errors. Use before release when changing session or room code.
|
||||
|
||||
## CI
|
||||
|
||||
GitHub CI runs `npm test` on Node 20 and 22. Gitea CI runs `npm test` on Node 22 plus a liveness `healthcheck`.
|
||||
|
||||
See [CI.md](./CI.md).
|
||||
|
||||
## Coverage philosophy
|
||||
|
||||
This template prioritizes **critical pure paths + one live DHT smoke test** over heavy mock frameworks. When productizing:
|
||||
|
||||
- Add domain unit tests next to new services
|
||||
- Keep integration tests few and deterministic
|
||||
- Gate flaky network tests behind `SKIP_INTEGRATION`
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PearData — P2P Fleet Monitoring</title>
|
||||
<link rel="stylesheet" href="./ui/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="titlebar" role="banner">
|
||||
<div class="titlebar-left">
|
||||
<pear-ctrl></pear-ctrl>
|
||||
<div class="app-brand" aria-hidden="true">
|
||||
<span class="logo">◈</span>
|
||||
<div class="app-brand-text">
|
||||
<strong>PearData</strong>
|
||||
<span class="muted">P2P · Netdata-class observability</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="titlebar-right">
|
||||
<span id="role-badge" class="badge">—</span>
|
||||
<div id="status-chip" class="chip offline">offline</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app">
|
||||
<aside class="fleet-rail panel">
|
||||
<h2>Fleet</h2>
|
||||
<p class="hint">Connect agents by public key or <code>pd1.</code> invite.</p>
|
||||
<label>
|
||||
Agent / invite
|
||||
<textarea id="connect-input" rows="2" placeholder="64-hex key or pd1.…"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
Admin seed (optional)
|
||||
<input id="admin-seed" type="password" autocomplete="off" placeholder="SERVER_SEED" />
|
||||
</label>
|
||||
<div class="row">
|
||||
<button id="btn-connect" class="primary">Connect</button>
|
||||
<button id="btn-disconnect" class="ghost" disabled>Disconnect</button>
|
||||
</div>
|
||||
<ul id="peer-list" class="peer-list"></ul>
|
||||
<div id="conn-meta" class="meta muted"></div>
|
||||
<div class="admin-block">
|
||||
<button id="btn-invite" class="ghost" disabled>Mint invite</button>
|
||||
<pre id="invite-out" class="invite-out hidden"></pre>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="dash-main">
|
||||
<section class="overview-strip panel">
|
||||
<div class="stat" data-stat="cpu">
|
||||
<span class="stat-label">CPU</span>
|
||||
<strong id="stat-cpu">—</strong>
|
||||
</div>
|
||||
<div class="stat" data-stat="ram">
|
||||
<span class="stat-label">RAM used</span>
|
||||
<strong id="stat-ram">—</strong>
|
||||
</div>
|
||||
<div class="stat" data-stat="load">
|
||||
<span class="stat-label">Load 1m</span>
|
||||
<strong id="stat-load">—</strong>
|
||||
</div>
|
||||
<div class="stat" data-stat="net">
|
||||
<span class="stat-label">Net RX</span>
|
||||
<strong id="stat-net">—</strong>
|
||||
</div>
|
||||
<div class="stat" data-stat="health">
|
||||
<span class="stat-label">Health</span>
|
||||
<strong id="stat-health">—</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="charts-grid">
|
||||
<article class="panel chart-panel">
|
||||
<header><h3>CPU</h3><span class="muted">system.cpu</span></header>
|
||||
<canvas id="chart-cpu" height="140"></canvas>
|
||||
</article>
|
||||
<article class="panel chart-panel">
|
||||
<header><h3>Memory</h3><span class="muted">system.ram</span></header>
|
||||
<canvas id="chart-ram" height="140"></canvas>
|
||||
</article>
|
||||
<article class="panel chart-panel">
|
||||
<header><h3>Network</h3><span class="muted">system.net</span></header>
|
||||
<canvas id="chart-net" height="140"></canvas>
|
||||
</article>
|
||||
<article class="panel chart-panel">
|
||||
<header><h3>Disk I/O</h3><span class="muted">system.io</span></header>
|
||||
<canvas id="chart-io" height="140"></canvas>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="bottom-row">
|
||||
<div class="panel">
|
||||
<h3>Anomalies</h3>
|
||||
<ul id="anomaly-list" class="event-list"></ul>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>Node</h3>
|
||||
<pre id="server-info" class="server-info muted">Not connected</pre>
|
||||
<h3>Log</h3>
|
||||
<pre id="log" class="log"></pre>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Pear desktop entrypoint.
|
||||
* Boots pear-electron UI + pear-bridge HTTP for the HTML app shell.
|
||||
*
|
||||
* @typedef {import('pear-interface')}
|
||||
*/
|
||||
/* global Pear */
|
||||
import Runtime from 'pear-electron'
|
||||
import Bridge from 'pear-bridge'
|
||||
|
||||
const bridge = new Bridge()
|
||||
await bridge.ready()
|
||||
|
||||
const runtime = new Runtime()
|
||||
const pipe = await runtime.start({ bridge })
|
||||
|
||||
const shutdown = async () => {
|
||||
try {
|
||||
Pear.exit()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
pipe.on('close', () => {
|
||||
shutdown()
|
||||
})
|
||||
|
||||
try {
|
||||
Pear.teardown?.(async () => {})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
Generated
+2230
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "peardata",
|
||||
"version": "0.1.0",
|
||||
"description": "Production-ready HyperDHT + protomux-rpc P2P app template (demo room + presence)",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"main": "index.js",
|
||||
"productName": "PearData",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"pear": {
|
||||
"pre": "pear-electron/pre",
|
||||
"name": "peardata",
|
||||
"gui": {
|
||||
"main": "index.html",
|
||||
"backgroundColor": "#0b1020",
|
||||
"height": 780,
|
||||
"width": 1100,
|
||||
"minWidth": 720,
|
||||
"minHeight": 480,
|
||||
"resizable": true,
|
||||
"movable": true,
|
||||
"minimizable": true,
|
||||
"maximizable": true,
|
||||
"closable": true,
|
||||
"hasShadow": true
|
||||
},
|
||||
"links": [
|
||||
"http://*",
|
||||
"https://*",
|
||||
"ws://*",
|
||||
"wss://*"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "pear run -d .",
|
||||
"start": "pear run -d .",
|
||||
"start:server": "node server/server.js",
|
||||
"server": "node server/server.js",
|
||||
"start:server:bin": "node bin/peardata-server.mjs",
|
||||
"test": "brittle-node test/*.test.js",
|
||||
"test:integration": "brittle-node test/integration.test.js",
|
||||
"healthcheck": "node scripts/healthcheck.js",
|
||||
"soak": "node scripts/soak.js",
|
||||
"mint-invite": "node scripts/mint-invite.js",
|
||||
"rename": "bash scripts/rename-template.sh",
|
||||
"release:notes": "node -e \"console.log('See docs/RELEASE.md')\""
|
||||
},
|
||||
"dependencies": {
|
||||
"b4a": "^1.8.1",
|
||||
"compact-encoding": "^3.3.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"graceful-goodbye": "^1.3.3",
|
||||
"hypercore-crypto": "^3.7.0",
|
||||
"hyperdht": "^6.33.0",
|
||||
"pear-bridge": "^1.2.5",
|
||||
"pear-electron": "^1.7.28",
|
||||
"pear-pipe": "^1.0.6",
|
||||
"pear-run": "^1.0.8",
|
||||
"protomux": "^3.11.0",
|
||||
"protomux-rpc": "^1.10.0",
|
||||
"safety-catch": "^1.0.3",
|
||||
"z32": "^1.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"brittle": "^4.1.0",
|
||||
"pear-interface": "^1.1.0"
|
||||
}
|
||||
}
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build source release artifacts and publish a rolling Gitea release.
|
||||
#
|
||||
# Required:
|
||||
# RELEASE_TOKEN — Gitea PAT with repository release write
|
||||
# Optional:
|
||||
# GITEA_URL / GITEA_OWNER / GITEA_REPO
|
||||
# RELEASE_TAG (default: rolling)
|
||||
# DRY_RUN=1 — build + stage only, no upload
|
||||
# GITHUB_SHA / GITEA_SHA — target commit for the release tag
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
VERSION="$(node -p "require('./package.json').version")"
|
||||
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
SHA="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||
FULL_SHA="${GITHUB_SHA:-${GITEA_SHA:-$(git rev-parse HEAD 2>/dev/null || echo main)}}"
|
||||
TAG="${RELEASE_TAG:-rolling}"
|
||||
PKG_NAME="$(node -p "require('./package.json').name")"
|
||||
RELEASE_TITLE="${RELEASE_NAME:-${PKG_NAME} rolling}"
|
||||
DIST="$ROOT/dist"
|
||||
|
||||
log() { echo "[release] $*"; }
|
||||
|
||||
detect_remote() {
|
||||
local url
|
||||
url="$(git remote get-url origin 2>/dev/null || true)"
|
||||
if [[ "$url" =~ git@([^:]+):([^/]+)/([^/.]+) ]]; then
|
||||
echo "https://${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
|
||||
elif [[ "$url" =~ https?://([^/]+)/([^/]+)/([^/.]+) ]]; then
|
||||
echo "https://${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}"
|
||||
else
|
||||
echo "" "" ""
|
||||
fi
|
||||
}
|
||||
|
||||
read -r DETECTED_URL DETECTED_OWNER DETECTED_REPO <<<"$(detect_remote)"
|
||||
GITEA_URL="${GITEA_URL:-${DETECTED_URL:-}}"
|
||||
GITEA_OWNER="${GITEA_OWNER:-${DETECTED_OWNER:-}}"
|
||||
GITEA_REPO="${GITEA_REPO:-${DETECTED_REPO:-${PKG_NAME}}}"
|
||||
|
||||
if [[ -z "${GITEA_URL}" ]]; then
|
||||
log "WARN: could not detect GITEA_URL — set GITEA_URL for upload"
|
||||
fi
|
||||
|
||||
# --- build artifacts (source tarball + checksum + notes) ---
|
||||
log "building release artifacts via scripts/release.sh"
|
||||
bash scripts/release.sh
|
||||
|
||||
# Enrich notes with rolling metadata (release.sh writes a base file)
|
||||
cat >"$DIST/RELEASE_NOTES.md" <<EOF
|
||||
# ${PKG_NAME} ${VERSION} (${TAG})
|
||||
|
||||
- Commit: \`${SHA}\` (\`${FULL_SHA}\`)
|
||||
- Built: ${STAMP}
|
||||
|
||||
HyperDHT + protomux-rpc application template.
|
||||
|
||||
## Contents
|
||||
- Node server (\`npm run start:server\`)
|
||||
- Pear desktop client (\`npm start\` / \`pear run -d .\`)
|
||||
- Demo room (messages + presence + invites)
|
||||
|
||||
## Install
|
||||
|
||||
\`\`\`bash
|
||||
mkdir -p app && tar -xzf ${PKG_NAME}-v${VERSION}.tar.gz -C app
|
||||
cd app
|
||||
npm install
|
||||
npm run start:server
|
||||
\`\`\`
|
||||
|
||||
## Verify
|
||||
|
||||
\`\`\`bash
|
||||
sha256sum -c ${PKG_NAME}-v${VERSION}.tar.gz.sha256
|
||||
\`\`\`
|
||||
|
||||
## Checksums
|
||||
|
||||
See \`*.sha256\` beside each archive.
|
||||
EOF
|
||||
|
||||
log "artifacts in $DIST:"
|
||||
ls -la "$DIST" || true
|
||||
|
||||
shopt -s nullglob
|
||||
ARTIFACTS=("$DIST"/*.tar.gz)
|
||||
if [[ ${#ARTIFACTS[@]} -eq 0 ]]; then
|
||||
log "ERROR: no tarball artifacts in $DIST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${DRY_RUN:-0}" == "1" ]]; then
|
||||
log "DRY_RUN=1 — skip Gitea upload"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -z "${RELEASE_TOKEN:-}" ]]; then
|
||||
log "ERROR: RELEASE_TOKEN is required (or DRY_RUN=1)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${GITEA_URL}" || -z "${GITEA_OWNER}" || -z "${GITEA_REPO}" ]]; then
|
||||
log "ERROR: GITEA_URL / GITEA_OWNER / GITEA_REPO must be set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
API="${GITEA_URL%/}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}"
|
||||
AUTH="Authorization: token ${RELEASE_TOKEN}"
|
||||
|
||||
log "Gitea API: $API tag=$TAG"
|
||||
|
||||
REL_JSON="$(curl -fsSL -H "$AUTH" "$API/releases/tags/${TAG}" 2>/dev/null || true)"
|
||||
if [[ -n "$REL_JSON" ]]; then
|
||||
REL_ID="$(node -e "try{const j=JSON.parse(process.argv[1]);console.log(j.id||'')}catch{console.log('')}" "$REL_JSON")"
|
||||
if [[ -n "$REL_ID" ]]; then
|
||||
log "deleting previous release id=$REL_ID"
|
||||
curl -fsSL -X DELETE -H "$AUTH" "$API/releases/$REL_ID" >/dev/null || true
|
||||
fi
|
||||
fi
|
||||
curl -fsSL -X DELETE -H "$AUTH" "$API/tags/${TAG}" >/dev/null 2>&1 || true
|
||||
|
||||
CREATE_BODY="$(node -e "
|
||||
const notes=require('fs').readFileSync(process.argv[1],'utf8');
|
||||
console.log(JSON.stringify({
|
||||
tag_name: process.argv[2],
|
||||
name: process.argv[3],
|
||||
body: notes,
|
||||
draft: false,
|
||||
prerelease: true,
|
||||
target_commitish: process.argv[4]
|
||||
}));
|
||||
" "$DIST/RELEASE_NOTES.md" "$TAG" "${RELEASE_TITLE} ${VERSION} ${SHA}" "$FULL_SHA")"
|
||||
|
||||
CREATE_RESP="$(curl -fsSL -X POST -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d "$CREATE_BODY" "$API/releases")"
|
||||
REL_ID="$(node -e "console.log(JSON.parse(process.argv[1]).id)" "$CREATE_RESP")"
|
||||
log "created release id=$REL_ID"
|
||||
|
||||
for f in "$DIST"/*; do
|
||||
[[ -f "$f" ]] || continue
|
||||
base="$(basename "$f")"
|
||||
case "$base" in
|
||||
*.tar.gz|*.sha256|*.md) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
log "upload $base"
|
||||
name_q="$(node -e "console.log(encodeURIComponent(process.argv[1]))" "$base")"
|
||||
curl -fsSL -X POST -H "$AUTH" \
|
||||
-F "attachment=@${f}" \
|
||||
"$API/releases/${REL_ID}/assets?name=${name_q}" \
|
||||
>/dev/null
|
||||
done
|
||||
|
||||
log "release published: ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}/releases/tag/${TAG}"
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Process supervision healthcheck.
|
||||
* If PEARDATA_HEALTH_KEY is set, dials the server and pings.
|
||||
* Otherwise exits 0 when the process can import server modules (liveness).
|
||||
*
|
||||
* Exit 0 = healthy, 1 = unhealthy.
|
||||
*/
|
||||
import { PearDataConnection } from '../client/connection.js'
|
||||
|
||||
const timeoutMs = Number(process.env.HEALTHCHECK_TIMEOUT_MS) || 8000
|
||||
const publicKey = process.env.PEARDATA_HEALTH_KEY || process.env.SERVER_PUBLIC_KEY
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
console.error('healthcheck: timeout')
|
||||
process.exit(1)
|
||||
}, timeoutMs)
|
||||
|
||||
try {
|
||||
if (!publicKey || !/^[0-9a-fA-F]{64}$/.test(publicKey)) {
|
||||
// Liveness without remote dial
|
||||
clearTimeout(timer)
|
||||
console.log('ok liveness')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const conn = new PearDataConnection(publicKey, {
|
||||
timeoutMs,
|
||||
adminSeed: process.env.SERVER_SEED || null,
|
||||
})
|
||||
await conn.connect()
|
||||
const pong = await conn.ping()
|
||||
await conn.destroy()
|
||||
clearTimeout(timer)
|
||||
if (!pong?.ok) throw new Error('ping failed')
|
||||
console.log('ok ping', pong.pong)
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
clearTimeout(timer)
|
||||
console.error('healthcheck failed:', err.message)
|
||||
process.exit(1)
|
||||
}
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Mint a pa1 invite from SERVER_SEED without starting the full server process.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/mint-invite.js [role=operator] [ttlMs]
|
||||
*/
|
||||
import dotenv from 'dotenv'
|
||||
import { loadOrCreateKeyPair } from '../server/core/keys.js'
|
||||
import { signCapability, encodeInvite } from '../shared/crypto-auth.js'
|
||||
|
||||
dotenv.config()
|
||||
const { publicKeyHex, seedHex } = loadOrCreateKeyPair()
|
||||
const role = process.argv[2] || 'operator'
|
||||
const ttlArg = process.argv[3]
|
||||
const ttlMs = ttlArg === undefined ? null : Number(ttlArg)
|
||||
|
||||
const { token, payload } = signCapability(seedHex, {
|
||||
role,
|
||||
ttlMs,
|
||||
forever: ttlMs == null || ttlMs === 0,
|
||||
})
|
||||
|
||||
const invite = encodeInvite({
|
||||
publicKeyHex,
|
||||
capability: token,
|
||||
role: payload.role,
|
||||
jti: payload.jti,
|
||||
expiresAt: payload.exp,
|
||||
})
|
||||
|
||||
console.log(invite)
|
||||
console.error(`# role=${payload.role} jti=${payload.jti} exp=${payload.exp ?? 'never'}`)
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
VERSION="$(node -p "require('./package.json').version")"
|
||||
NAME="peardata-v${VERSION}"
|
||||
mkdir -p dist
|
||||
tar --exclude=node_modules --exclude=.git --exclude=data --exclude=dist \
|
||||
-czf "dist/${NAME}.tar.gz" .
|
||||
(
|
||||
cd dist
|
||||
if command -v sha256sum >/dev/null; then
|
||||
sha256sum "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256"
|
||||
else
|
||||
shasum -a 256 "${NAME}.tar.gz" > "${NAME}.tar.gz.sha256"
|
||||
fi
|
||||
)
|
||||
cat > dist/RELEASE_NOTES.md <<EOF
|
||||
# ${NAME}
|
||||
|
||||
HyperDHT + protomux-rpc application template.
|
||||
|
||||
## Contents
|
||||
- Node server (\`npm run start:server\`)
|
||||
- Pear desktop client (\`npm start\` / \`pear run -d .\`)
|
||||
- Demo room (messages + presence + invites)
|
||||
|
||||
## Verify
|
||||
\`\`\`
|
||||
sha256sum -c ${NAME}.tar.gz.sha256
|
||||
\`\`\`
|
||||
EOF
|
||||
ls -la dist
|
||||
echo "Release artifacts ready in dist/"
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rebrand peardata → your product name.
|
||||
# Usage: bash scripts/rename-template.sh my-app MyApp
|
||||
set -euo pipefail
|
||||
|
||||
OLD_SLUG="peardata"
|
||||
OLD_NAME="peardata"
|
||||
OLD_PRODUCT="PearData"
|
||||
OLD_PROTOCOL="peardata/rpc"
|
||||
OLD_PREFIX="pd1."
|
||||
OLD_ENV="PEARDATA_"
|
||||
|
||||
NEW_SLUG="${1:-}"
|
||||
NEW_PRODUCT="${2:-}"
|
||||
|
||||
if [[ -z "$NEW_SLUG" || -z "$NEW_PRODUCT" ]]; then
|
||||
echo "Usage: $0 <slug> <ProductName>"
|
||||
echo " example: $0 notes-mesh NotesMesh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "$NEW_SLUG" =~ ^[a-z][a-z0-9-]*$ ]]; then
|
||||
echo "slug must be lowercase alphanumeric + dashes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# Derive invite prefix from first letters + 1.
|
||||
PREFIX="$(echo "$NEW_SLUG" | tr -cd 'a-z' | cut -c1-2)1."
|
||||
ENV_PREFIX="$(echo "$NEW_SLUG" | tr 'a-z-' 'A-Z_')_"
|
||||
ENV_PREFIX="${ENV_PREFIX//__/_}"
|
||||
|
||||
echo "Renaming:"
|
||||
echo " package: $OLD_NAME → $NEW_SLUG"
|
||||
echo " product: $OLD_PRODUCT → $NEW_PRODUCT"
|
||||
echo " protocol: $OLD_PROTOCOL → ${NEW_SLUG}/rpc"
|
||||
echo " invite: $OLD_PREFIX → $PREFIX"
|
||||
echo " env: $OLD_ENV → $ENV_PREFIX"
|
||||
|
||||
export LC_ALL=C
|
||||
find . -type f \
|
||||
\( -name '*.js' -o -name '*.mjs' -o -name '*.cjs' -o -name '*.json' -o -name '*.md' -o -name '*.html' -o -name '*.css' -o -name '*.yml' -o -name '*.yaml' -o -name '*.service' -o -name '*.example' -o -name '*.sh' \) \
|
||||
! -path './node_modules/*' ! -path './.git/*' ! -path './data/*' \
|
||||
-print0 | while IFS= read -r -d '' f; do
|
||||
perl -pi -e "
|
||||
s/\Q$OLD_NAME\E/$NEW_SLUG/g;
|
||||
s/\Q$OLD_PRODUCT\E/$NEW_PRODUCT/g;
|
||||
s/\Q$OLD_PROTOCOL\E/${NEW_SLUG}\\/rpc/g;
|
||||
s/\Q$OLD_SLUG\E/$NEW_SLUG/g;
|
||||
s/\Q$OLD_PREFIX\E/$PREFIX/g;
|
||||
s/\Q$OLD_ENV\E/$ENV_PREFIX/g;
|
||||
" "$f"
|
||||
done
|
||||
|
||||
if [[ -f bin/peardata-server.mjs ]]; then
|
||||
mv bin/peardata-server.mjs "bin/${NEW_SLUG}-server.mjs"
|
||||
fi
|
||||
if [[ -f deploy/peardata.service ]]; then
|
||||
mv deploy/peardata.service "deploy/${NEW_SLUG}.service"
|
||||
fi
|
||||
|
||||
echo "Done. Review git diff, then: npm install && npm test"
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Soak test: connect, post messages, verify pushes for SOAK_DURATION_MS.
|
||||
*
|
||||
* Usage:
|
||||
* SERVER_PUBLIC_KEY=… SERVER_SEED=… node scripts/soak.js
|
||||
*/
|
||||
import { PearDataConnection } from '../client/connection.js'
|
||||
import { Methods, Pushes } from '../shared/protocol.js'
|
||||
|
||||
const duration = Number(process.env.SOAK_DURATION_MS) || 60_000
|
||||
const publicKey = process.env.SERVER_PUBLIC_KEY
|
||||
const seed = process.env.SERVER_SEED
|
||||
|
||||
if (!publicKey) {
|
||||
console.error('SERVER_PUBLIC_KEY required')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const conn = new PearDataConnection(publicKey, {
|
||||
adminSeed: seed || null,
|
||||
timeoutMs: 30_000,
|
||||
})
|
||||
|
||||
let received = 0
|
||||
let sent = 0
|
||||
let errors = 0
|
||||
|
||||
conn.on(Pushes.message, () => {
|
||||
received++
|
||||
})
|
||||
|
||||
await conn.connect()
|
||||
console.log('soak connected as', conn.role)
|
||||
|
||||
const end = Date.now() + duration
|
||||
while (Date.now() < end) {
|
||||
try {
|
||||
await conn.request(Methods.postMessage, {
|
||||
text: `soak ${sent} @ ${new Date().toISOString()}`,
|
||||
})
|
||||
sent++
|
||||
await conn.ping()
|
||||
} catch (err) {
|
||||
errors++
|
||||
console.error('soak error', err.message)
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, Number(process.env.SOAK_INTERVAL_MS) || 500))
|
||||
}
|
||||
|
||||
await conn.destroy()
|
||||
console.log(JSON.stringify({ duration, sent, received, errors }, null, 2))
|
||||
process.exit(errors > 0 ? 1 : 0)
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Capability / role ACL for RPC methods.
|
||||
*
|
||||
* Secure default: every peer is viewer (read-only).
|
||||
* Elevate via:
|
||||
* - admin seed HMAC proof (handshake)
|
||||
* - HMAC capability grant (pa1 invite)
|
||||
* - PEARDATA_ADMIN_KEYS peer allowlist
|
||||
* - peer policy registered role
|
||||
* - PEARDATA_INSECURE_OPEN_ADMIN=1 (dev escape hatch)
|
||||
*/
|
||||
import { Roles, roleAllows, MethodRoles } from '../../shared/protocol.js'
|
||||
import { resolvePeerRole } from './peer-policy.js'
|
||||
import { isInsecureOpenAdmin } from '../../shared/crypto-auth.js'
|
||||
|
||||
const DEFAULT_ROLE = (process.env.PEARDATA_DEFAULT_ROLE || Roles.viewer).toLowerCase()
|
||||
const ADMIN_KEYS = new Set(
|
||||
(process.env.PEARDATA_ADMIN_KEYS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
/**
|
||||
* @param {string} peerIdHex
|
||||
*/
|
||||
export function resolveRole(peerIdHex) {
|
||||
const id = (peerIdHex || '').toLowerCase()
|
||||
|
||||
if (isInsecureOpenAdmin()) {
|
||||
try {
|
||||
return resolvePeerRole(id, Roles.admin)
|
||||
} catch {
|
||||
return Roles.admin
|
||||
}
|
||||
}
|
||||
|
||||
let envRole = Roles.viewer
|
||||
if (ADMIN_KEYS.size > 0) {
|
||||
envRole = ADMIN_KEYS.has(id) ? Roles.admin : DEFAULT_ROLE
|
||||
} else if ([Roles.viewer, Roles.operator, Roles.admin].includes(DEFAULT_ROLE)) {
|
||||
envRole = DEFAULT_ROLE
|
||||
}
|
||||
|
||||
if (envRole === Roles.admin && ADMIN_KEYS.size === 0 && DEFAULT_ROLE !== Roles.admin) {
|
||||
envRole = Roles.viewer
|
||||
}
|
||||
|
||||
try {
|
||||
return resolvePeerRole(id, envRole)
|
||||
} catch {
|
||||
return envRole
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} role
|
||||
* @param {string} method
|
||||
*/
|
||||
export function assertAllowed(role, method) {
|
||||
if (!roleAllows(role, MethodRoles[method] || Roles.admin)) {
|
||||
const need = MethodRoles[method] || Roles.admin
|
||||
const err = new Error(`Permission denied: ${method} requires role "${need}" (have "${role}")`)
|
||||
err.code = 'PERMISSION_DENIED'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
*/
|
||||
export function maxRole(a, b) {
|
||||
const rank = { [Roles.viewer]: 1, [Roles.operator]: 2, [Roles.admin]: 3 }
|
||||
return (rank[a] || 0) >= (rank[b] || 0) ? a : b
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Append-only audit log for sensitive RPCs.
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const MUTATING = new Set([
|
||||
'postMessage',
|
||||
'clearMessages',
|
||||
'mintInvite',
|
||||
'revokePeer',
|
||||
'handshake',
|
||||
])
|
||||
|
||||
function auditPath() {
|
||||
const dir = process.env.PEARDATA_DATA_DIR || path.resolve('data')
|
||||
return path.join(dir, 'audit.log')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
*/
|
||||
export function shouldAudit(method) {
|
||||
return MUTATING.has(method)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ method: string, peerId: string, role: string, ok: boolean, error?: string, args?: object, force?: boolean }} entry
|
||||
*/
|
||||
export function audit(entry) {
|
||||
if (!entry.force && !shouldAudit(entry.method)) return
|
||||
const line = JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
method: entry.method,
|
||||
peerId: String(entry.peerId || '').slice(0, 16),
|
||||
role: entry.role,
|
||||
ok: entry.ok,
|
||||
error: entry.error || null,
|
||||
})
|
||||
try {
|
||||
const dir = path.dirname(auditPath())
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
fs.appendFileSync(auditPath(), line + '\n', { mode: 0o600 })
|
||||
} catch {
|
||||
// never break RPC on audit failure
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Server MAC key derived from SERVER_SEED for capabilities + admin proofs.
|
||||
*/
|
||||
import { deriveMacKey } from '../../shared/crypto-auth.js'
|
||||
|
||||
let macKey = null
|
||||
let serverPublicKeyHex = null
|
||||
let seedHex = null
|
||||
|
||||
/**
|
||||
* @param {{ seedHex: string, publicKeyHex: string }} opts
|
||||
*/
|
||||
export function initAuthKeys(opts) {
|
||||
seedHex = opts.seedHex
|
||||
serverPublicKeyHex = opts.publicKeyHex
|
||||
macKey = deriveMacKey(seedHex)
|
||||
}
|
||||
|
||||
export function getMacKey() {
|
||||
if (!macKey) throw new Error('Auth keys not initialized')
|
||||
return macKey
|
||||
}
|
||||
|
||||
export function getServerPublicKeyHex() {
|
||||
return serverPublicKeyHex
|
||||
}
|
||||
|
||||
export function getSeedHex() {
|
||||
return seedHex
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Persistent HyperDHT keypair management.
|
||||
* SERVER_SEED (32-byte hex) is the secret seed.
|
||||
* Clients connect using the derived public key.
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import DHT from 'hyperdht'
|
||||
import b4a from 'b4a'
|
||||
import crypto from 'hypercore-crypto'
|
||||
import dotenv from 'dotenv'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const log = logger.child('keys')
|
||||
|
||||
/**
|
||||
* @param {string} [envPath]
|
||||
*/
|
||||
export function loadOrCreateKeyPair(envPath = '.env') {
|
||||
let seedHex = process.env.SERVER_SEED || process.env.SERVER_KEY
|
||||
|
||||
if (!seedHex) {
|
||||
const seed = crypto.randomBytes(32)
|
||||
seedHex = b4a.toString(seed, 'hex')
|
||||
const publicKeyHex = b4a.toString(DHT.keyPair(seed).publicKey, 'hex')
|
||||
const line = `\nSERVER_SEED=${seedHex}\nSERVER_PUBLIC_KEY=${publicKeyHex}\n`
|
||||
fs.appendFileSync(envPath, line, { flag: 'a' })
|
||||
log.info('Generated new SERVER_SEED and SERVER_PUBLIC_KEY', {
|
||||
path: path.resolve(envPath),
|
||||
})
|
||||
}
|
||||
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(seedHex)) {
|
||||
throw new Error('SERVER_SEED must be 64 hex characters (32 bytes)')
|
||||
}
|
||||
|
||||
const seed = b4a.from(seedHex, 'hex')
|
||||
const keyPair = DHT.keyPair(seed)
|
||||
const publicKeyHex = b4a.toString(keyPair.publicKey, 'hex')
|
||||
|
||||
if (process.env.SERVER_PUBLIC_KEY !== publicKeyHex) {
|
||||
try {
|
||||
let env = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : ''
|
||||
if (env.includes('SERVER_PUBLIC_KEY=')) {
|
||||
env = env.replace(/SERVER_PUBLIC_KEY=.*/g, `SERVER_PUBLIC_KEY=${publicKeyHex}`)
|
||||
} else {
|
||||
env += `\nSERVER_PUBLIC_KEY=${publicKeyHex}\n`
|
||||
}
|
||||
if (!env.includes('SERVER_SEED=')) {
|
||||
env += `SERVER_SEED=${seedHex}\n`
|
||||
}
|
||||
fs.writeFileSync(envPath, env)
|
||||
} catch (err) {
|
||||
log.warn('Could not update .env with SERVER_PUBLIC_KEY', { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
process.env.SERVER_SEED = seedHex
|
||||
process.env.SERVER_PUBLIC_KEY = publicKeyHex
|
||||
|
||||
return { seed, keyPair, publicKeyHex, seedHex }
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Peer roles, revocations, capability spend tracking.
|
||||
* File-backed under PEARDATA_DATA_DIR (default ./data).
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Roles } from '../../shared/protocol.js'
|
||||
import { verifyCapability } from '../../shared/crypto-auth.js'
|
||||
import { getMacKey } from './auth-keys.js'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
const log = logger.child('peer-policy')
|
||||
|
||||
function dataDir() {
|
||||
return process.env.PEARDATA_DATA_DIR || path.resolve('data')
|
||||
}
|
||||
|
||||
function policyPath() {
|
||||
return path.join(dataDir(), 'peer-policy.json')
|
||||
}
|
||||
|
||||
/** @type {{ peers: Record<string, { role: string, displayName?: string, firstSeen: string, lastSeen: string }>, revoked: string[], spentJti: string[] }} */
|
||||
let state = { peers: {}, revoked: [], spentJti: [] }
|
||||
|
||||
export function loadPeerPolicy() {
|
||||
try {
|
||||
const p = policyPath()
|
||||
if (fs.existsSync(p)) {
|
||||
const raw = JSON.parse(fs.readFileSync(p, 'utf8'))
|
||||
state = {
|
||||
peers: raw.peers || {},
|
||||
revoked: Array.isArray(raw.revoked) ? raw.revoked : [],
|
||||
spentJti: Array.isArray(raw.spentJti) ? raw.spentJti.slice(-5000) : [],
|
||||
}
|
||||
log.info('Loaded peer policy', {
|
||||
peers: Object.keys(state.peers).length,
|
||||
revoked: state.revoked.length,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Failed to load peer policy', { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
try {
|
||||
const dir = dataDir()
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
fs.writeFileSync(policyPath(), JSON.stringify(state, null, 2), { mode: 0o600 })
|
||||
} catch (err) {
|
||||
log.warn('Failed to save peer policy', { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerId
|
||||
* @param {string} fallback
|
||||
*/
|
||||
export function resolvePeerRole(peerId, fallback) {
|
||||
const id = String(peerId || '').toLowerCase()
|
||||
const entry = state.peers[id]
|
||||
if (entry?.role) return entry.role
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerId
|
||||
*/
|
||||
export function getPeerEntry(peerId) {
|
||||
return state.peers[String(peerId || '').toLowerCase()] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerId
|
||||
* @param {{ role: string, displayName?: string }} info
|
||||
*/
|
||||
export function registerPeer(peerId, info) {
|
||||
const id = String(peerId || '').toLowerCase()
|
||||
const now = new Date().toISOString()
|
||||
const prev = state.peers[id]
|
||||
state.peers[id] = {
|
||||
role: info.role,
|
||||
displayName: info.displayName || prev?.displayName,
|
||||
firstSeen: prev?.firstSeen || now,
|
||||
lastSeen: now,
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerId
|
||||
* @param {string} [displayName]
|
||||
*/
|
||||
export function touchPeer(peerId, displayName) {
|
||||
const id = String(peerId || '').toLowerCase()
|
||||
const prev = state.peers[id]
|
||||
if (!prev) return
|
||||
prev.lastSeen = new Date().toISOString()
|
||||
if (displayName) prev.displayName = displayName
|
||||
save()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerId
|
||||
*/
|
||||
export function isPeerRevoked(peerId) {
|
||||
return state.revoked.includes(String(peerId || '').toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerId
|
||||
* @param {{ authMode?: string }} [opts]
|
||||
*/
|
||||
export function isPeerAllowed(peerId, opts = {}) {
|
||||
const id = String(peerId || '').toLowerCase()
|
||||
if (isPeerRevoked(id)) return false
|
||||
const allow = (process.env.PEARDATA_ALLOWLIST || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
if (allow.length === 0) return true
|
||||
// Seed/capability auth can onboard onto allowlist environments
|
||||
if (opts.authMode === 'seed' || opts.authMode === 'capability') return true
|
||||
return allow.includes(id) || Boolean(state.peers[id])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerId
|
||||
*/
|
||||
export function revokePeer(peerId) {
|
||||
const id = String(peerId || '').toLowerCase()
|
||||
if (!state.revoked.includes(id)) state.revoked.push(id)
|
||||
delete state.peers[id]
|
||||
save()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} token
|
||||
* @param {string} peerId
|
||||
* @returns {{ role: string, reconnected: boolean }}
|
||||
*/
|
||||
export function redeemCapability(token, peerId) {
|
||||
const spent = new Set(state.spentJti)
|
||||
const res = verifyCapability(getMacKey(), token, {
|
||||
peerId,
|
||||
allowSpentCheck: (jti) => {
|
||||
// Already-registered peers may reconnect with same grant
|
||||
if (spent.has(jti) && getPeerEntry(peerId)?.role) return true
|
||||
return !spent.has(jti)
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = new Error(res.error)
|
||||
err.code = res.code
|
||||
throw err
|
||||
}
|
||||
|
||||
const jti = res.payload.jti
|
||||
const existing = getPeerEntry(peerId)
|
||||
const reconnected = Boolean(existing?.role) && spent.has(jti)
|
||||
|
||||
if (!spent.has(jti)) {
|
||||
state.spentJti.push(jti)
|
||||
if (state.spentJti.length > 5000) state.spentJti = state.spentJti.slice(-4000)
|
||||
}
|
||||
|
||||
registerPeer(peerId, { role: res.payload.role })
|
||||
save()
|
||||
return { role: res.payload.role, reconnected }
|
||||
}
|
||||
|
||||
export function listPolicyPeers() {
|
||||
return Object.entries(state.peers).map(([id, p]) => ({
|
||||
peerId: id,
|
||||
...p,
|
||||
revoked: isPeerRevoked(id),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Live connected peer sessions.
|
||||
*/
|
||||
|
||||
/** @type {Map<string, import('../rpc/session.js').PeerSession>} */
|
||||
const byId = new Map()
|
||||
|
||||
export const peers = {
|
||||
/**
|
||||
* @param {import('../rpc/session.js').PeerSession} session
|
||||
*/
|
||||
add(session) {
|
||||
byId.set(session.id, session)
|
||||
},
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
remove(id) {
|
||||
byId.delete(id)
|
||||
},
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
get(id) {
|
||||
return byId.get(id) || null
|
||||
},
|
||||
list() {
|
||||
return [...byId.values()]
|
||||
},
|
||||
size() {
|
||||
return byId.size
|
||||
},
|
||||
/**
|
||||
* Push event to every connected peer.
|
||||
* @param {string} event
|
||||
* @param {unknown} payload
|
||||
*/
|
||||
broadcast(event, payload) {
|
||||
for (const s of byId.values()) {
|
||||
try {
|
||||
s.push(event, payload)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* PearMonitor agent RPC handlers — metrics, anomalies, alerts, jobs, ACL.
|
||||
*/
|
||||
import os from 'os'
|
||||
import {
|
||||
APP_NAME,
|
||||
APP_VERSION,
|
||||
PROTOCOL,
|
||||
PROTOCOL_VERSION,
|
||||
Roles,
|
||||
} from '../../shared/protocol.js'
|
||||
import { SCHEMA_VERSION } from '../../shared/schema.js'
|
||||
import { CHART_DEFS, CHART_BY_ID, CONTEXT_IDS, chartSummary } from '../../shared/metrics.js'
|
||||
import { peers } from '../core/peer-registry.js'
|
||||
import {
|
||||
listPolicyPeers,
|
||||
revokePeer as policyRevoke,
|
||||
touchPeer,
|
||||
} from '../core/peer-policy.js'
|
||||
import { signCapability, encodeInvite } from '../../shared/crypto-auth.js'
|
||||
import { getMacKey, getServerPublicKeyHex } from '../core/auth-keys.js'
|
||||
import { getCollector } from '../services/collector.js'
|
||||
import { getStore } from '../services/store.js'
|
||||
import { getAnomalyEngine } from '../services/anomaly.js'
|
||||
import {
|
||||
listAlerts,
|
||||
getAlert,
|
||||
setAlertConfig,
|
||||
ackAlert,
|
||||
silenceAlert,
|
||||
} from '../services/alerts.js'
|
||||
import {
|
||||
subscribeMetrics,
|
||||
unsubscribeMetrics,
|
||||
subscribeAnomalies,
|
||||
unsubscribeAnomalies,
|
||||
} from '../services/subscriptions.js'
|
||||
import { getJobs, knownJobNames } from '../services/jobs.js'
|
||||
import { formatAllMetrics } from '../rest/formatters.js'
|
||||
|
||||
/**
|
||||
* @param {import('../rpc/session.js').PeerSession} session
|
||||
*/
|
||||
export function registerMonitorHandlers(session) {
|
||||
const collector = getCollector()
|
||||
const store = getStore()
|
||||
const anomalies = getAnomalyEngine(os.cpus().length)
|
||||
|
||||
session.respond('ping', async () => ({
|
||||
ok: true,
|
||||
pong: Date.now(),
|
||||
peerId: session.id,
|
||||
}), { hot: true })
|
||||
|
||||
session.respond('getServerInfo', async () => ({
|
||||
app: APP_NAME,
|
||||
version: APP_VERSION,
|
||||
protocol: PROTOCOL,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
publicKeyHex: getServerPublicKeyHex(),
|
||||
hostname: os.hostname(),
|
||||
platform: `${os.platform()}/${os.arch()}`,
|
||||
uptimeSec: Math.floor(process.uptime()),
|
||||
connectedPeers: peers.size(),
|
||||
node: process.version,
|
||||
role: 'agent',
|
||||
charts: CHART_DEFS.length,
|
||||
}))
|
||||
|
||||
session.respond('getAuthStatus', async () => ({
|
||||
peerId: session.id,
|
||||
role: session.role,
|
||||
authMode: session.authMode,
|
||||
displayName: session.displayName,
|
||||
}))
|
||||
|
||||
session.respond('setDisplayName', async (args, s) => {
|
||||
s.displayName = args.name
|
||||
touchPeer(s.id, args.name)
|
||||
return { success: true, displayName: args.name }
|
||||
})
|
||||
|
||||
session.respond('getNodeInfo', async () =>
|
||||
collector.getNodeInfo(getServerPublicKeyHex(), APP_VERSION)
|
||||
)
|
||||
|
||||
session.respond('getHealth', async () => anomalies.getHealth())
|
||||
|
||||
session.respond('listContexts', async () => ({
|
||||
contexts: CONTEXT_IDS.map((id) => {
|
||||
const charts = CHART_DEFS.filter((c) => c.context === id)
|
||||
return {
|
||||
id,
|
||||
family: charts[0]?.family || id.split('.')[0],
|
||||
title: charts[0]?.title || id,
|
||||
charts: charts.map((c) => c.id),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
session.respond('getContext', async (args) => {
|
||||
const charts = CHART_DEFS.filter((c) => c.context === args.id || c.id === args.id)
|
||||
if (!charts.length) return { error: 'unknown context', id: args.id }
|
||||
return {
|
||||
id: args.id,
|
||||
charts: charts.map((c) => store.getMeta(c.id) || chartSummary(c)),
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('listCharts', async () => ({
|
||||
charts: store.listChartSummaries(),
|
||||
hostname: os.hostname(),
|
||||
version: APP_VERSION,
|
||||
}))
|
||||
|
||||
session.respond('getChart', async (args) => {
|
||||
const meta = store.getMeta(args.id)
|
||||
if (!meta) {
|
||||
const def = CHART_BY_ID.get(args.id)
|
||||
if (!def) return { error: 'unknown chart', id: args.id }
|
||||
return chartSummary(def)
|
||||
}
|
||||
return meta
|
||||
})
|
||||
|
||||
session.respond('queryData', async (args) => store.query(args), { hot: true })
|
||||
|
||||
session.respond('getAllMetrics', async (args) => formatAllMetrics(args.format || 'json'))
|
||||
|
||||
session.respond('subscribeMetrics', async (args, s) => subscribeMetrics(s, args), {
|
||||
hot: true,
|
||||
})
|
||||
session.respond('unsubscribeMetrics', async (_a, s) => unsubscribeMetrics(s), { hot: true })
|
||||
session.respond('subscribeAnomalies', async (_a, s) => subscribeAnomalies(s))
|
||||
session.respond('unsubscribeAnomalies', async (_a, s) => unsubscribeAnomalies(s))
|
||||
|
||||
session.respond('listAnomalies', async (args) => ({
|
||||
anomalies: anomalies.listRecent(args?.limit || 50),
|
||||
}))
|
||||
|
||||
session.respond('listAlerts', async () => ({ alerts: listAlerts() }))
|
||||
session.respond('getAlert', async (args) => {
|
||||
const a = getAlert(args.id)
|
||||
return a || { error: 'unknown alert', id: args.id }
|
||||
})
|
||||
session.respond('setAlertConfig', async (args) => ({
|
||||
success: true,
|
||||
config: setAlertConfig(args),
|
||||
}))
|
||||
session.respond('ackAlert', async (args) => ackAlert(args.id))
|
||||
session.respond('silenceAlert', async (args) => silenceAlert(args.id, args))
|
||||
|
||||
session.respond('listJobs', async () => ({
|
||||
jobs: getJobs().list(),
|
||||
known: knownJobNames(),
|
||||
}))
|
||||
session.respond('runJob', async (args) => getJobs().run(args.name, args.args || {}))
|
||||
session.respond('cancelJob', async (args) => getJobs().cancel(args.id))
|
||||
|
||||
session.respond('mintInvite', async (args) => {
|
||||
const role = args.role || Roles.operator
|
||||
const ttlMs = args.ttlMs === undefined ? null : args.ttlMs
|
||||
const { token, payload } = signCapability(getMacKey(), {
|
||||
role,
|
||||
ttlMs,
|
||||
forever: ttlMs == null || ttlMs === 0,
|
||||
peerId: args.peerId || null,
|
||||
})
|
||||
const invite = encodeInvite({
|
||||
publicKeyHex: getServerPublicKeyHex(),
|
||||
capability: token,
|
||||
role: payload.role,
|
||||
jti: payload.jti,
|
||||
alias: args.alias || null,
|
||||
expiresAt: payload.exp,
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
invite,
|
||||
capability: token,
|
||||
role: payload.role,
|
||||
jti: payload.jti,
|
||||
exp: payload.exp,
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('listPeers', async () => ({
|
||||
connected: peers.list().map((s) => ({
|
||||
peerId: s.id,
|
||||
role: s.role,
|
||||
displayName: s.displayName,
|
||||
authMode: s.authMode,
|
||||
})),
|
||||
known: listPolicyPeers(),
|
||||
}))
|
||||
|
||||
session.respond('revokePeer', async (args) => {
|
||||
policyRevoke(args.peerId)
|
||||
const live = peers.get(args.peerId)
|
||||
if (live) live.destroy()
|
||||
return { success: true, peerId: args.peerId }
|
||||
})
|
||||
|
||||
session.respond('exportSnapshot', async () => ({
|
||||
success: true,
|
||||
node: collector.getNodeInfo(getServerPublicKeyHex(), APP_VERSION),
|
||||
latest: store.latestValues(),
|
||||
health: anomalies.getHealth(),
|
||||
alerts: listAlerts(),
|
||||
ts: Date.now(),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Agent data pipeline: collector → store → anomaly → push fan-out.
|
||||
*/
|
||||
import os from 'os'
|
||||
import { getCollector } from './services/collector.js'
|
||||
import { getStore } from './services/store.js'
|
||||
import { getAnomalyEngine } from './services/anomaly.js'
|
||||
import {
|
||||
broadcastMetrics,
|
||||
broadcastAnomaly,
|
||||
broadcastHealth,
|
||||
} from './services/subscriptions.js'
|
||||
import { Pushes } from '../shared/protocol.js'
|
||||
import { peers } from './core/peer-registry.js'
|
||||
import logger from './utils/logger.js'
|
||||
|
||||
const log = logger.child('pipeline')
|
||||
let healthEvery = 0
|
||||
|
||||
export function startPipeline() {
|
||||
const collector = getCollector()
|
||||
const store = getStore()
|
||||
const anomalies = getAnomalyEngine(os.cpus().length)
|
||||
|
||||
collector.on('samples', (batch) => {
|
||||
store.ingest(batch)
|
||||
broadcastMetrics(batch)
|
||||
|
||||
const fired = anomalies.evaluate(batch)
|
||||
for (const ev of fired) {
|
||||
broadcastAnomaly(ev)
|
||||
if (!ev.cleared) {
|
||||
peers.broadcast(Pushes.alert, {
|
||||
id: ev.id,
|
||||
status: ev.severity === 'critical' ? 'CRITICAL' : 'WARNING',
|
||||
...ev,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// periodic health push (~15s)
|
||||
healthEvery++
|
||||
if (healthEvery >= 15) {
|
||||
healthEvery = 0
|
||||
broadcastHealth(anomalies.getHealth())
|
||||
}
|
||||
})
|
||||
|
||||
collector.start()
|
||||
log.info('Metrics pipeline started')
|
||||
return { collector, store, anomalies }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Export formatters (JSON / Prometheus / shell) for /api/v*/allmetrics.
|
||||
*/
|
||||
import os from 'os'
|
||||
import { getStore } from '../services/store.js'
|
||||
import { CHART_BY_ID } from '../../shared/metrics.js'
|
||||
import { APP_NAME, APP_VERSION } from '../../shared/protocol.js'
|
||||
|
||||
/**
|
||||
* @param {'json'|'prometheus'|'shell'} format
|
||||
*/
|
||||
export function formatAllMetrics(format = 'json') {
|
||||
const latest = getStore().latestValues()
|
||||
if (format === 'prometheus') return { contentType: 'text/plain; version=0.0.4', body: toPrometheus(latest) }
|
||||
if (format === 'shell') return { contentType: 'text/plain', body: toShell(latest) }
|
||||
return {
|
||||
contentType: 'application/json',
|
||||
body: {
|
||||
hostname: os.hostname(),
|
||||
app: APP_NAME,
|
||||
version: APP_VERSION,
|
||||
charts: Object.fromEntries(
|
||||
Object.entries(latest).map(([chart, point]) => [
|
||||
chart,
|
||||
{
|
||||
name: chart,
|
||||
context: CHART_BY_ID.get(chart)?.context || chart,
|
||||
last_updated: Math.floor(point.ts / 1000),
|
||||
dimensions: point.values,
|
||||
},
|
||||
])
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, { ts: number, values: Record<string, number|null> }>} latest
|
||||
*/
|
||||
function toPrometheus(latest) {
|
||||
const lines = [`# HELP peardata_info PearData agent info`, `# TYPE peardata_info gauge`]
|
||||
lines.push(`peardata_info{version="${APP_VERSION}",hostname="${os.hostname()}"} 1`)
|
||||
for (const [chart, point] of Object.entries(latest)) {
|
||||
const metric = chart.replace(/\./g, '_')
|
||||
for (const [dim, val] of Object.entries(point.values)) {
|
||||
if (val == null || Number.isNaN(val)) continue
|
||||
lines.push(`${metric}{dimension="${dim}"} ${val}`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, { ts: number, values: Record<string, number|null> }>} latest
|
||||
*/
|
||||
function toShell(latest) {
|
||||
const lines = []
|
||||
for (const [chart, point] of Object.entries(latest)) {
|
||||
for (const [dim, val] of Object.entries(point.values)) {
|
||||
if (val == null || Number.isNaN(val)) continue
|
||||
lines.push(`NETDATA_${chart.replace(/\./g, '_').toUpperCase()}_${dim.toUpperCase()}="${val}"`)
|
||||
}
|
||||
}
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Optional Netdata-style HTTP API bound to the PearMonitor agent.
|
||||
*
|
||||
* Default: 127.0.0.1:19999 (Netdata's classic port) — local only.
|
||||
* Disable with PEARDATA_REST=0
|
||||
*/
|
||||
import http from 'http'
|
||||
import { handleRest } from './routes.js'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
const log = logger.child('rest')
|
||||
|
||||
/**
|
||||
* @returns {http.Server|null}
|
||||
*/
|
||||
export function startRestServer() {
|
||||
if (process.env.PEARDATA_REST === '0' || process.env.PEARDATA_REST === 'off') {
|
||||
log.info('REST API disabled (PEARDATA_REST=0)')
|
||||
return null
|
||||
}
|
||||
|
||||
const host = process.env.PEARDATA_REST_HOST || '127.0.0.1'
|
||||
const port = Number(process.env.PEARDATA_REST_PORT) || 19999
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', `http://${host}:${port}`)
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS') {
|
||||
res.writeHead(405, { 'content-type': 'application/json', allow: 'GET, HEAD, OPTIONS' })
|
||||
res.end(JSON.stringify({ error: 'method not allowed' }))
|
||||
return
|
||||
}
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204, corsHeaders())
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const result = handleRest(url.pathname, url.searchParams)
|
||||
const headers = {
|
||||
...corsHeaders(),
|
||||
'content-type': result.contentType || 'application/json',
|
||||
'cache-control': 'no-cache',
|
||||
'x-peardata-api': 'v3',
|
||||
}
|
||||
const body =
|
||||
typeof result.body === 'string' ? result.body : JSON.stringify(result.body, null, 0)
|
||||
|
||||
if (req.method === 'HEAD') {
|
||||
headers['content-length'] = Buffer.byteLength(body)
|
||||
res.writeHead(result.status || 200, headers)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
res.writeHead(result.status || 200, headers)
|
||||
res.end(body)
|
||||
} catch (err) {
|
||||
log.error('REST handler error', { error: err.message })
|
||||
res.writeHead(500, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'internal error' }))
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(port, host, () => {
|
||||
log.info('REST API listening', {
|
||||
url: `http://${host}:${port}`,
|
||||
examples: [
|
||||
`http://${host}:${port}/api/v3/info`,
|
||||
`http://${host}:${port}/api/v3/data?chart=system.cpu&after=-60&points=60`,
|
||||
`http://${host}:${port}/api/v1/charts`,
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
server.on('error', (err) => {
|
||||
log.error('REST server error', { error: err.message })
|
||||
})
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
function corsHeaders() {
|
||||
return {
|
||||
'access-control-allow-origin': process.env.PEARDATA_REST_CORS || '*',
|
||||
'access-control-allow-methods': 'GET, HEAD, OPTIONS',
|
||||
'access-control-allow-headers': 'Content-Type, Authorization',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Netdata-compatible REST route handlers (v1 + v2 + v3).
|
||||
*
|
||||
* Local-agent style GET endpoints. Not a byte-for-byte clone of every
|
||||
* Netdata field, but intentional compatibility for charts/data/contexts/nodes/info/allmetrics.
|
||||
*/
|
||||
import os from 'os'
|
||||
import { APP_NAME, APP_VERSION, PROTOCOL, PROTOCOL_VERSION } from '../../shared/protocol.js'
|
||||
import { CHART_DEFS, CHART_BY_ID, CONTEXT_IDS, chartSummary } from '../../shared/metrics.js'
|
||||
import { getStore } from '../services/store.js'
|
||||
import { getCollector } from '../services/collector.js'
|
||||
import { getAnomalyEngine } from '../services/anomaly.js'
|
||||
import { listAlerts, getAlert } from '../services/alerts.js'
|
||||
import { getServerPublicKeyHex } from '../core/auth-keys.js'
|
||||
import { formatAllMetrics } from './formatters.js'
|
||||
import { peers } from '../core/peer-registry.js'
|
||||
|
||||
/**
|
||||
* @param {string} pathname
|
||||
* @param {URLSearchParams} query
|
||||
* @returns {{ status: number, contentType: string, body: any }}
|
||||
*/
|
||||
export function handleRest(pathname, query) {
|
||||
const path = pathname.replace(/\/+$/, '') || '/'
|
||||
|
||||
// ── info / versions ──────────────────────────────────────
|
||||
if (path === '/api/v1/info' || path === '/api/v2/info' || path === '/api/v3/info') {
|
||||
return json(infoPayload())
|
||||
}
|
||||
if (path === '/api/v3/versions') {
|
||||
return json({
|
||||
agent: APP_VERSION,
|
||||
protocol: PROTOCOL,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
api: ['v1', 'v2', 'v3'],
|
||||
node: process.version,
|
||||
})
|
||||
}
|
||||
if (path === '/api/v3/me') {
|
||||
return json({
|
||||
authenticated: false,
|
||||
role: 'anonymous-rest',
|
||||
note: 'REST is local by default; P2P uses pubkey/invite roles',
|
||||
})
|
||||
}
|
||||
|
||||
// ── nodes ────────────────────────────────────────────────
|
||||
if (path === '/api/v2/nodes' || path === '/api/v3/nodes') {
|
||||
const node = nodePayload()
|
||||
return json({ nodes: [node], ...([node][0] && {}) })
|
||||
}
|
||||
if (path === '/api/v3/node_instances') {
|
||||
return json({ nodes: [nodePayload()] })
|
||||
}
|
||||
|
||||
// ── contexts ─────────────────────────────────────────────
|
||||
if (path === '/api/v2/contexts' || path === '/api/v3/contexts') {
|
||||
return json({
|
||||
contexts: Object.fromEntries(
|
||||
CONTEXT_IDS.map((id) => {
|
||||
const charts = CHART_DEFS.filter((c) => c.context === id)
|
||||
return [
|
||||
id,
|
||||
{
|
||||
family: charts[0]?.family,
|
||||
title: charts[0]?.title,
|
||||
units: charts[0]?.units,
|
||||
charts: charts.map((c) => c.id),
|
||||
},
|
||||
]
|
||||
})
|
||||
),
|
||||
})
|
||||
}
|
||||
if (path === '/api/v3/context' || path === '/api/v2/context') {
|
||||
const id = query.get('context') || query.get('id') || ''
|
||||
const charts = CHART_DEFS.filter((c) => c.context === id || c.id === id)
|
||||
if (!charts.length) return err(404, 'unknown context')
|
||||
return json({
|
||||
id,
|
||||
charts: charts.map((c) => getStore().getMeta(c.id) || chartSummary(c)),
|
||||
})
|
||||
}
|
||||
|
||||
// ── charts (v1 legacy + still useful) ────────────────────
|
||||
if (path === '/api/v1/charts') {
|
||||
return json({
|
||||
hostname: os.hostname(),
|
||||
version: APP_VERSION,
|
||||
os: `${os.platform()} ${os.release()}`,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
history: Number(process.env.PEARDATA_TIER0_POINTS) || 3600,
|
||||
update_every: 1,
|
||||
charts: getStore().listChartSummaries(),
|
||||
})
|
||||
}
|
||||
if (path === '/api/v1/chart') {
|
||||
const id = query.get('chart') || ''
|
||||
const meta = getStore().getMeta(id) || (CHART_BY_ID.has(id) ? chartSummary(CHART_BY_ID.get(id)) : null)
|
||||
if (!meta) return err(404, 'unknown chart')
|
||||
return json(meta)
|
||||
}
|
||||
|
||||
// ── data queries ─────────────────────────────────────────
|
||||
if (
|
||||
path === '/api/v1/data' ||
|
||||
path === '/api/v2/data' ||
|
||||
path === '/api/v3/data'
|
||||
) {
|
||||
const chart = query.get('chart') || query.get('context') || query.get('scopes') || ''
|
||||
if (!chart) return err(400, 'chart or context required')
|
||||
// scopes may be comma-separated contexts — take first for MVP
|
||||
const chartId = chart.split(',')[0].trim()
|
||||
const resolved = CHART_BY_ID.has(chartId)
|
||||
? chartId
|
||||
: CHART_DEFS.find((c) => c.context === chartId)?.id || chartId
|
||||
const result = getStore().query({
|
||||
chart: resolved,
|
||||
after: num(query.get('after'), -60),
|
||||
before: num(query.get('before'), 0),
|
||||
points: num(query.get('points'), 60),
|
||||
group: query.get('group') || query.get('time_group') || 'average',
|
||||
tier: num(query.get('tier'), 0),
|
||||
})
|
||||
if (result.error) return err(404, result.error)
|
||||
const format = query.get('format') || 'json'
|
||||
if (format === 'csv') {
|
||||
const lines = [result.labels.join(',')]
|
||||
for (const row of result.data) lines.push(row.join(','))
|
||||
return { status: 200, contentType: 'text/csv', body: lines.join('\n') + '\n' }
|
||||
}
|
||||
if (format === 'array') {
|
||||
return json(result.data)
|
||||
}
|
||||
return json(result)
|
||||
}
|
||||
|
||||
// ── weights / q (stubs with useful MVP behavior) ─────────
|
||||
if (path === '/api/v3/weights' || path === '/api/v2/weights') {
|
||||
const health = getAnomalyEngine().getHealth()
|
||||
return json({
|
||||
status: health.status,
|
||||
score: health.score,
|
||||
results: health.checks.map((c) => ({
|
||||
id: c.id,
|
||||
weight: c.ok ? 0 : 1,
|
||||
info: c.detail,
|
||||
})),
|
||||
})
|
||||
}
|
||||
if (path === '/api/v3/q' || path === '/api/v2/q') {
|
||||
const q = (query.get('q') || query.get('query') || '').toLowerCase()
|
||||
const hits = CHART_DEFS.filter(
|
||||
(c) =>
|
||||
!q ||
|
||||
c.id.includes(q) ||
|
||||
c.title.toLowerCase().includes(q) ||
|
||||
c.context.includes(q) ||
|
||||
c.family.includes(q)
|
||||
).map((c) => ({ type: 'chart', id: c.id, title: c.title, context: c.context }))
|
||||
return json({ results: hits, q })
|
||||
}
|
||||
|
||||
// ── alerts ───────────────────────────────────────────────
|
||||
if (
|
||||
path === '/api/v1/alarms' ||
|
||||
path === '/api/v2/alerts' ||
|
||||
path === '/api/v3/alerts'
|
||||
) {
|
||||
return json({ alerts: listAlerts(), alarms: listAlerts() })
|
||||
}
|
||||
if (path === '/api/v1/alarm_variables' || path === '/api/v3/variable') {
|
||||
const id = query.get('alarm') || query.get('alert') || query.get('name') || ''
|
||||
const a = id ? getAlert(id) : null
|
||||
return json(a || { alerts: listAlerts() })
|
||||
}
|
||||
if (path === '/api/v3/alert_transitions') {
|
||||
return json({ transitions: getAnomalyEngine().listRecent(100) })
|
||||
}
|
||||
if (path === '/api/v3/alert_config') {
|
||||
return json({ alerts: getAnomalyEngine().listConfigs() })
|
||||
}
|
||||
|
||||
// ── allmetrics export ────────────────────────────────────
|
||||
if (
|
||||
path === '/api/v1/allmetrics' ||
|
||||
path === '/api/v2/allmetrics' ||
|
||||
path === '/api/v3/allmetrics'
|
||||
) {
|
||||
const format = (query.get('format') || 'json').toLowerCase()
|
||||
const out = formatAllMetrics(format)
|
||||
return {
|
||||
status: 200,
|
||||
contentType: out.contentType,
|
||||
body: typeof out.body === 'string' ? out.body : out.body,
|
||||
}
|
||||
}
|
||||
|
||||
// ── badge ────────────────────────────────────────────────
|
||||
if (path === '/api/v1/badge.svg' || path === '/api/v3/badge.svg') {
|
||||
const chart = query.get('chart') || 'system.cpu'
|
||||
const dim = query.get('dimensions') || query.get('dimension') || 'user'
|
||||
const latest = getStore().latestValues()[chart]
|
||||
const val = latest?.values?.[dim]
|
||||
const label = query.get('label') || `${chart}.${dim}`
|
||||
const text = val == null ? 'n/a' : String(Math.round(val * 100) / 100)
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="20">
|
||||
<rect width="160" height="20" fill="#555"/>
|
||||
<rect x="90" width="70" height="20" fill="#4c1"/>
|
||||
<text x="45" y="14" fill="#fff" font-family="sans-serif" font-size="11" text-anchor="middle">${escapeXml(label)}</text>
|
||||
<text x="125" y="14" fill="#fff" font-family="sans-serif" font-size="11" text-anchor="middle">${escapeXml(text)}</text>
|
||||
</svg>`
|
||||
return { status: 200, contentType: 'image/svg+xml', body: svg }
|
||||
}
|
||||
|
||||
// ── functions / settings stubs ───────────────────────────
|
||||
if (path === '/api/v3/functions' || path === '/api/v2/functions') {
|
||||
return json({ functions: [{ name: 'collectOnce' }, { name: 'snapshot' }, { name: 'gcBuffers' }] })
|
||||
}
|
||||
if (path === '/api/v3/settings' || path === '/api/v3/config') {
|
||||
return json({
|
||||
sample_ms: Number(process.env.PEARDATA_SAMPLE_MS) || 1000,
|
||||
rest_host: process.env.PEARDATA_REST_HOST || '127.0.0.1',
|
||||
rest_port: Number(process.env.PEARDATA_REST_PORT) || 19999,
|
||||
tier0_points: Number(process.env.PEARDATA_TIER0_POINTS) || 3600,
|
||||
})
|
||||
}
|
||||
if (path === '/api/v3/stream_path') {
|
||||
return json({
|
||||
path: [
|
||||
{
|
||||
node: getServerPublicKeyHex(),
|
||||
hostname: os.hostname(),
|
||||
hops: 0,
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
// ── health / root ────────────────────────────────────────
|
||||
if (path === '/api/v1/health' || path === '/health' || path === '/api/v3/health') {
|
||||
return json(getAnomalyEngine().getHealth())
|
||||
}
|
||||
if (path === '/' || path === '/api') {
|
||||
return json({
|
||||
name: APP_NAME,
|
||||
version: APP_VERSION,
|
||||
apis: ['/api/v1', '/api/v2', '/api/v3'],
|
||||
docs: 'See docs/REST-API.md',
|
||||
p2p: { protocol: PROTOCOL, publicKeyHex: getServerPublicKeyHex() },
|
||||
})
|
||||
}
|
||||
|
||||
return err(404, `not found: ${path}`)
|
||||
}
|
||||
|
||||
function infoPayload() {
|
||||
const c = getCollector()
|
||||
return {
|
||||
version: APP_VERSION,
|
||||
uid: getServerPublicKeyHex(),
|
||||
mirrored_hosts: [os.hostname()],
|
||||
mirrored_hosts_status: [{ hostname: os.hostname(), reachable: true }],
|
||||
alarms: { normal: 0, warning: 0, critical: 0 },
|
||||
os_name: os.platform(),
|
||||
os_id: os.release(),
|
||||
cores_total: os.cpus().length,
|
||||
total_ram: os.totalmem(),
|
||||
hostname: os.hostname(),
|
||||
collected: c.sampleCount,
|
||||
update_every: 1,
|
||||
peers_connected: peers.size(),
|
||||
peardata: {
|
||||
protocol: PROTOCOL,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
publicKeyHex: getServerPublicKeyHex(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function nodePayload() {
|
||||
const pk = getServerPublicKeyHex()
|
||||
return {
|
||||
nm: os.hostname(),
|
||||
nd: pk?.slice(0, 16),
|
||||
guid: pk,
|
||||
hw: {
|
||||
cpu_cores: os.cpus().length,
|
||||
ram_total: os.totalmem(),
|
||||
},
|
||||
os: {
|
||||
id: os.platform(),
|
||||
nm: os.release(),
|
||||
},
|
||||
st: 'online',
|
||||
}
|
||||
}
|
||||
|
||||
function json(body) {
|
||||
return { status: 200, contentType: 'application/json', body }
|
||||
}
|
||||
|
||||
function err(status, message) {
|
||||
return { status, contentType: 'application/json', body: { error: message, status } }
|
||||
}
|
||||
|
||||
function num(v, fallback) {
|
||||
if (v == null || v === '') return fallback
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
function escapeXml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Wire all RPC handlers onto a PeerSession.
|
||||
*/
|
||||
import { registerHandshake } from './session.js'
|
||||
import { registerMonitorHandlers } from '../handlers/monitor.js'
|
||||
|
||||
/**
|
||||
* @param {import('./session.js').PeerSession} session
|
||||
*/
|
||||
export function registerAllHandlers(session) {
|
||||
registerHandshake(session)
|
||||
registerMonitorHandlers(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('./session.js').PeerSession} session
|
||||
*/
|
||||
export function cleanupSession(session) {
|
||||
session.state.clear()
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* ProtomuxRPC session wrapping a HyperDHT secret stream.
|
||||
*/
|
||||
import ProtomuxRPC from 'protomux-rpc'
|
||||
import b4a from 'b4a'
|
||||
import { PROTOCOL, PROTOCOL_VERSION, Roles } from '../../shared/protocol.js'
|
||||
import { encodings } from '../../shared/encodings.js'
|
||||
import rateLimiter from '../utils/rateLimiter.js'
|
||||
import logger from '../utils/logger.js'
|
||||
import { resolveRole, assertAllowed, maxRole } from '../core/acl.js'
|
||||
import { audit, shouldAudit } from '../core/audit.js'
|
||||
import {
|
||||
redeemCapability,
|
||||
isPeerAllowed,
|
||||
getPeerEntry,
|
||||
registerPeer,
|
||||
touchPeer,
|
||||
} from '../core/peer-policy.js'
|
||||
import { validateMethodArgs, SCHEMA_VERSION } from '../../shared/schema.js'
|
||||
import { verifyAdminProof } from '../../shared/crypto-auth.js'
|
||||
import { getMacKey, getServerPublicKeyHex } from '../core/auth-keys.js'
|
||||
|
||||
export class PeerSession {
|
||||
/**
|
||||
* @param {import('stream').Duplex} stream
|
||||
* @param {{ serverPublicKey: Uint8Array, onClose?: (s: PeerSession) => void }} opts
|
||||
*/
|
||||
constructor(stream, { serverPublicKey, onClose } = {}) {
|
||||
this.stream = stream
|
||||
this.id = stream.remotePublicKey
|
||||
? b4a.toString(stream.remotePublicKey, 'hex')
|
||||
: `anon-${Date.now()}`
|
||||
this.remotePublicKey = stream.remotePublicKey
|
||||
this.closed = false
|
||||
this.onClose = onClose
|
||||
this.role = resolveRole(this.id)
|
||||
this.clientInfo = null
|
||||
this.authMode = 'viewer'
|
||||
this.displayName = null
|
||||
|
||||
/** @type {Map<string, any>} */
|
||||
this.state = new Map()
|
||||
|
||||
this.rpc = new ProtomuxRPC(stream, {
|
||||
id: serverPublicKey,
|
||||
protocol: PROTOCOL,
|
||||
...encodings,
|
||||
})
|
||||
|
||||
this.rpc.on('close', () => this._handleClose())
|
||||
this.rpc.on('destroy', () => this._handleClose())
|
||||
stream.on('close', () => this._handleClose())
|
||||
stream.on('error', (err) => {
|
||||
logger.error('Peer stream error', {
|
||||
peerId: this.id.slice(0, 12),
|
||||
error: err.message,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {(args: any, session: PeerSession) => Promise<any>|any} handler
|
||||
* @param {{ hot?: boolean }} [opts]
|
||||
*/
|
||||
respond(method, handler, opts = {}) {
|
||||
const hot = opts.hot === true || rateLimiter.isStreamMethod?.(method)
|
||||
this.rpc.respond(method, encodings, async (args) => {
|
||||
if (!rateLimiter.isAllowed(this, method)) {
|
||||
const err = new Error('Rate limit exceeded. Please wait before making more requests.')
|
||||
err.code = 'RATE_LIMIT_EXCEEDED'
|
||||
throw err
|
||||
}
|
||||
|
||||
if (hot) {
|
||||
try {
|
||||
assertAllowed(this.role, method)
|
||||
return await handler(args ?? {}, this)
|
||||
} catch (err) {
|
||||
if (err?.code === 'PERMISSION_DENIED') {
|
||||
audit({
|
||||
method,
|
||||
peerId: this.id,
|
||||
role: this.role,
|
||||
ok: false,
|
||||
error: err.message,
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
const safe = new Error(sanitizeError(err))
|
||||
safe.code = err.code || 'UNKNOWN_ERROR'
|
||||
throw safe
|
||||
}
|
||||
}
|
||||
|
||||
const t0 = Date.now()
|
||||
try {
|
||||
assertAllowed(this.role, method)
|
||||
const validated = validateMethodArgs(method, args ?? {})
|
||||
if (!validated.ok) {
|
||||
const err = new Error(validated.error || 'Invalid arguments')
|
||||
err.code = 'INVALID_ARGS'
|
||||
throw err
|
||||
}
|
||||
const result = await handler(validated.args, this)
|
||||
if (shouldAudit(method)) {
|
||||
audit({
|
||||
method,
|
||||
peerId: this.id,
|
||||
role: this.role,
|
||||
ok: true,
|
||||
args: args ?? {},
|
||||
})
|
||||
}
|
||||
const latencyMs = Date.now() - t0
|
||||
if (latencyMs >= 2000) {
|
||||
logger.warn('Slow RPC', {
|
||||
method,
|
||||
peerId: this.id.slice(0, 12),
|
||||
latencyMs,
|
||||
})
|
||||
} else {
|
||||
logger.debug('RPC ok', {
|
||||
method,
|
||||
peerId: this.id.slice(0, 12),
|
||||
latencyMs,
|
||||
})
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
if (shouldAudit(method) || err?.code === 'PERMISSION_DENIED') {
|
||||
audit({
|
||||
method,
|
||||
peerId: this.id,
|
||||
role: this.role,
|
||||
ok: false,
|
||||
error: err.message,
|
||||
args: args ?? {},
|
||||
force: err?.code === 'PERMISSION_DENIED',
|
||||
})
|
||||
}
|
||||
logger.error('RPC handler failed', {
|
||||
method,
|
||||
peerId: this.id.slice(0, 12),
|
||||
role: this.role,
|
||||
error: err.message,
|
||||
code: err.code,
|
||||
})
|
||||
const safe = new Error(sanitizeError(err))
|
||||
safe.code = err.code || 'UNKNOWN_ERROR'
|
||||
throw safe
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {unknown} payload
|
||||
*/
|
||||
push(method, payload) {
|
||||
if (this.closed || this.rpc.closed) return
|
||||
this.rpc.event(method, payload, encodings)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
try {
|
||||
this.rpc.destroy()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
this.stream.destroy()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
_handleClose() {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
if (this.onClose) this.onClose(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PeerSession} session
|
||||
*/
|
||||
export function registerHandshake(session) {
|
||||
session.respond('handshake', async (args) => {
|
||||
if (args?.clientName || args?.clientVersion) {
|
||||
session.clientInfo = {
|
||||
name: args.clientName || 'unknown',
|
||||
version: args.clientVersion || null,
|
||||
}
|
||||
}
|
||||
|
||||
let authMode = 'viewer'
|
||||
let elevatedRole = null
|
||||
|
||||
if (args?.adminProof) {
|
||||
const serverPk = getServerPublicKeyHex()
|
||||
const proofRes = verifyAdminProof(getMacKey(), args.adminProof, {
|
||||
peerId: session.id,
|
||||
serverPublicKeyHex: serverPk || '',
|
||||
})
|
||||
if (!proofRes.ok) {
|
||||
const e = new Error(proofRes.error || 'Admin proof failed')
|
||||
e.code = proofRes.code || 'ADMIN_PROOF_FAILED'
|
||||
audit({
|
||||
method: 'handshake',
|
||||
peerId: session.id,
|
||||
role: session.role,
|
||||
ok: false,
|
||||
error: e.message,
|
||||
force: true,
|
||||
})
|
||||
throw e
|
||||
}
|
||||
elevatedRole = Roles.admin
|
||||
authMode = 'seed'
|
||||
registerPeer(session.id, { role: Roles.admin })
|
||||
}
|
||||
|
||||
const capabilityToken = args?.capability || null
|
||||
if (capabilityToken && authMode !== 'seed') {
|
||||
try {
|
||||
const { role, reconnected } = redeemCapability(String(capabilityToken), session.id)
|
||||
elevatedRole = role
|
||||
authMode = reconnected ? 'registered' : 'capability'
|
||||
} catch (err) {
|
||||
const registered = getPeerEntry(session.id)
|
||||
if (registered?.role && registered.role !== Roles.viewer) {
|
||||
elevatedRole = registered.role
|
||||
authMode = 'registered'
|
||||
} else {
|
||||
const e = new Error(err.message || 'Capability redeem failed')
|
||||
e.code = err.code || 'CAPABILITY_INVALID'
|
||||
audit({
|
||||
method: 'handshake',
|
||||
peerId: session.id,
|
||||
role: session.role,
|
||||
ok: false,
|
||||
error: e.message,
|
||||
force: true,
|
||||
})
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!elevatedRole && authMode === 'viewer') {
|
||||
const registered = getPeerEntry(session.id)
|
||||
if (registered?.role) {
|
||||
elevatedRole = registered.role
|
||||
authMode = 'registered'
|
||||
}
|
||||
}
|
||||
|
||||
const baseline = resolveRole(session.id)
|
||||
session.role = elevatedRole ? maxRole(baseline, elevatedRole) : baseline
|
||||
session.authMode = authMode
|
||||
|
||||
if (!isPeerAllowed(session.id, { authMode })) {
|
||||
const err = new Error('Peer not allowed (revoked or not on allowlist)')
|
||||
err.code = 'PEER_DENIED'
|
||||
throw err
|
||||
}
|
||||
|
||||
touchPeer(session.id)
|
||||
logger.info('Handshake complete', {
|
||||
peerId: session.id.slice(0, 12),
|
||||
role: session.role,
|
||||
authMode,
|
||||
})
|
||||
|
||||
audit({
|
||||
method: 'handshake',
|
||||
peerId: session.id,
|
||||
role: session.role,
|
||||
ok: true,
|
||||
force: true,
|
||||
args: { authMode, role: session.role },
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
protocol: PROTOCOL,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
role: session.role,
|
||||
peerId: session.id,
|
||||
serverTime: Date.now(),
|
||||
auth: { mode: authMode, role: session.role },
|
||||
features: {
|
||||
schemaValidation: true,
|
||||
hmacAuth: true,
|
||||
invites: true,
|
||||
room: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function sanitizeError(err) {
|
||||
const msg = String(err?.message || err || 'Unknown error')
|
||||
return msg.length > 800 ? msg.slice(0, 800) + '…' : msg
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* PearData / PearMonitor agent entry point.
|
||||
*
|
||||
* - HyperDHT listener + protomux-rpc (P2P control + metric streams)
|
||||
* - Optional Netdata-compatible REST API (default :19999)
|
||||
* - 1s system metrics pipeline
|
||||
*/
|
||||
import DHT from 'hyperdht'
|
||||
import b4a from 'b4a'
|
||||
import gracefulGoodbye from 'graceful-goodbye'
|
||||
import { loadOrCreateKeyPair } from './core/keys.js'
|
||||
import { initAuthKeys } from './core/auth-keys.js'
|
||||
import { peers } from './core/peer-registry.js'
|
||||
import { PeerSession } from './rpc/session.js'
|
||||
import { registerAllHandlers, cleanupSession } from './rpc/register.js'
|
||||
import { isPeerRevoked, loadPeerPolicy } from './core/peer-policy.js'
|
||||
import { isInsecureOpenAdmin } from '../shared/crypto-auth.js'
|
||||
import { APP_NAME, APP_VERSION } from '../shared/protocol.js'
|
||||
import { startPipeline } from './pipeline.js'
|
||||
import { startRestServer } from './rest/http-server.js'
|
||||
import { getCollector } from './services/collector.js'
|
||||
import logger from './utils/logger.js'
|
||||
|
||||
const bootStarted = Date.now()
|
||||
const log = logger.child('server')
|
||||
|
||||
/** @type {{ keyPair: any, publicKeyHex: string, seedHex: string }} */
|
||||
let keyPair
|
||||
let publicKeyHex
|
||||
let seedHex
|
||||
try {
|
||||
;({ keyPair, publicKeyHex, seedHex } = loadOrCreateKeyPair())
|
||||
initAuthKeys({ seedHex, publicKeyHex })
|
||||
loadPeerPolicy()
|
||||
} catch (err) {
|
||||
log.error('Failed to load server keypair', { error: err.message })
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (isInsecureOpenAdmin()) {
|
||||
log.warn(
|
||||
'PEARDATA_INSECURE_OPEN_ADMIN=1 — every peer is admin. Disable for production multi-operator use.'
|
||||
)
|
||||
}
|
||||
|
||||
startPipeline()
|
||||
const restServer = startRestServer()
|
||||
|
||||
const dht = new DHT()
|
||||
const server = dht.createServer()
|
||||
|
||||
server.on('connection', (socket) => {
|
||||
const peerId = socket.remotePublicKey
|
||||
? b4a.toString(socket.remotePublicKey, 'hex')
|
||||
: null
|
||||
|
||||
if (peerId && isPeerRevoked(peerId)) {
|
||||
log.warn('Rejected revoked peer', { peerId: peerId.slice(0, 12) })
|
||||
try {
|
||||
socket.destroy()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const session = new PeerSession(socket, {
|
||||
serverPublicKey: keyPair.publicKey,
|
||||
onClose: (s) => {
|
||||
cleanupSession(s)
|
||||
peers.remove(s.id)
|
||||
log.info('Peer disconnected', {
|
||||
peerId: s.id.slice(0, 12),
|
||||
role: s.role,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
registerAllHandlers(session)
|
||||
peers.add(session)
|
||||
|
||||
log.info('Peer connected', {
|
||||
peerId: session.id.slice(0, 12),
|
||||
role: session.role,
|
||||
connected: peers.size(),
|
||||
})
|
||||
})
|
||||
|
||||
await server.listen(keyPair)
|
||||
|
||||
const bootMs = Date.now() - bootStarted
|
||||
const restPort = Number(process.env.PEARDATA_REST_PORT) || 19999
|
||||
const restHost = process.env.PEARDATA_REST_HOST || '127.0.0.1'
|
||||
|
||||
logger.banner({
|
||||
title: `${APP_NAME} agent v${APP_VERSION}`,
|
||||
publicKey: publicKeyHex,
|
||||
connect: `Client → dial ${publicKeyHex}`,
|
||||
admin: 'Use SERVER_SEED as admin proof, or mint pd1 invites',
|
||||
rest: restServer ? `http://${restHost}:${restPort}/api/v3/info` : 'disabled',
|
||||
insecure: isInsecureOpenAdmin() ? 'OPEN ADMIN (dev)' : 'secure defaults',
|
||||
bootMs: `${bootMs}ms`,
|
||||
})
|
||||
|
||||
log.info('HyperDHT agent listening', { publicKeyHex })
|
||||
|
||||
async function shutdown() {
|
||||
log.info('Shutting down…')
|
||||
try {
|
||||
getCollector().stop()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
for (const s of peers.list()) {
|
||||
try {
|
||||
s.destroy()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (restServer) {
|
||||
await new Promise((resolve) => restServer.close(() => resolve()))
|
||||
}
|
||||
try {
|
||||
await server.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await dht.destroy()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
gracefulGoodbye(shutdown)
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
|
||||
export { publicKeyHex, dht, server, restServer }
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Alert state machine wrapping AnomalyEngine configs.
|
||||
*/
|
||||
import { getAnomalyEngine } from './anomaly.js'
|
||||
|
||||
/**
|
||||
* @returns {import('../../shared/data-model.js').AlertState[]}
|
||||
*/
|
||||
export function listAlerts() {
|
||||
const engine = getAnomalyEngine()
|
||||
return engine.listConfigs().map((cfg) => {
|
||||
const status = engine.status.get(cfg.id) || 'CLEAR'
|
||||
return {
|
||||
id: cfg.id,
|
||||
name: cfg.id,
|
||||
chart: cfg.chart,
|
||||
dimension: cfg.dimension,
|
||||
status,
|
||||
value: null,
|
||||
units: '',
|
||||
info: cfg.info || '',
|
||||
lastStatusChange: Date.now(),
|
||||
config: cfg,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getAlert(id) {
|
||||
return listAlerts().find((a) => a.id === id) || null
|
||||
}
|
||||
|
||||
export function setAlertConfig(cfg) {
|
||||
return getAnomalyEngine().setConfig(cfg)
|
||||
}
|
||||
|
||||
/** Soft ack — clears active status until next breach. */
|
||||
export function ackAlert(id) {
|
||||
const engine = getAnomalyEngine()
|
||||
if (!engine.configs.has(id)) return { success: false, error: 'unknown alert' }
|
||||
engine.status.set(id, 'CLEAR')
|
||||
return { success: true, id }
|
||||
}
|
||||
|
||||
export function silenceAlert(id, opts = {}) {
|
||||
const engine = getAnomalyEngine()
|
||||
const cfg = engine.configs.get(id)
|
||||
if (!cfg) return { success: false, error: 'unknown alert' }
|
||||
const until = opts.until || Date.now() + (opts.ttlMs || 3600_000)
|
||||
engine.setConfig({ ...cfg, enabled: false, _silencedUntil: until })
|
||||
return { success: true, id, until }
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Threshold-based anomaly detection (MVP).
|
||||
* Later: scoring, k-means consensus, ML jobs.
|
||||
*/
|
||||
import { EventEmitter } from 'events'
|
||||
import { normalizeAnomaly } from '../../shared/data-model.js'
|
||||
|
||||
/** @typedef {import('../../shared/data-model.js').AlertConfig} AlertConfig */
|
||||
/** @typedef {import('../../shared/data-model.js').AnomalyEvent} AnomalyEvent */
|
||||
|
||||
/** Default thresholds — tunable via setAlertConfig / REST. */
|
||||
export const DEFAULT_THRESHOLDS = [
|
||||
{
|
||||
id: 'cpu_user_high',
|
||||
chart: 'system.cpu',
|
||||
dimension: 'user',
|
||||
warn: 80,
|
||||
crit: 95,
|
||||
comparator: '>',
|
||||
enabled: true,
|
||||
info: 'CPU user time high',
|
||||
},
|
||||
{
|
||||
id: 'load1_high',
|
||||
chart: 'system.load',
|
||||
dimension: 'load1',
|
||||
warn: null, // set dynamically from cpu count in evaluate
|
||||
crit: null,
|
||||
comparator: '>',
|
||||
enabled: true,
|
||||
info: 'Load average high vs CPU count',
|
||||
_dynamicLoad: true,
|
||||
},
|
||||
{
|
||||
id: 'mem_avail_low',
|
||||
chart: 'mem.available',
|
||||
dimension: 'avail',
|
||||
warn: 512,
|
||||
crit: 256,
|
||||
comparator: '<',
|
||||
enabled: true,
|
||||
info: 'Available memory low (MiB)',
|
||||
},
|
||||
]
|
||||
|
||||
function compare(op, value, threshold) {
|
||||
if (threshold == null || value == null || Number.isNaN(value)) return false
|
||||
switch (op) {
|
||||
case '<':
|
||||
return value < threshold
|
||||
case '<=':
|
||||
return value <= threshold
|
||||
case '>=':
|
||||
return value >= threshold
|
||||
case '>':
|
||||
default:
|
||||
return value > threshold
|
||||
}
|
||||
}
|
||||
|
||||
export class AnomalyEngine extends EventEmitter {
|
||||
/**
|
||||
* @param {{ cpuCount?: number }} [opts]
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
super()
|
||||
this.cpuCount = opts.cpuCount || 1
|
||||
/** @type {Map<string, AlertConfig & { _dynamicLoad?: boolean }>} */
|
||||
this.configs = new Map(DEFAULT_THRESHOLDS.map((c) => [c.id, { ...c }]))
|
||||
/** @type {Map<string, string>} status CLEAR|WARNING|CRITICAL */
|
||||
this.status = new Map()
|
||||
/** @type {AnomalyEvent[]} */
|
||||
this.recent = []
|
||||
this.recentMax = 500
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {AlertConfig} cfg
|
||||
*/
|
||||
setConfig(cfg) {
|
||||
const prev = this.configs.get(cfg.id) || {}
|
||||
this.configs.set(cfg.id, { ...prev, ...cfg, id: cfg.id })
|
||||
return this.configs.get(cfg.id)
|
||||
}
|
||||
|
||||
listConfigs() {
|
||||
return [...this.configs.values()]
|
||||
}
|
||||
|
||||
listRecent(limit = 50) {
|
||||
return this.recent.slice(-limit).reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} batch
|
||||
*/
|
||||
evaluate(batch) {
|
||||
/** @type {AnomalyEvent[]} */
|
||||
const fired = []
|
||||
const byChart = new Map(batch.map((s) => [s.chart, s]))
|
||||
|
||||
for (const cfg of this.configs.values()) {
|
||||
if (cfg.enabled === false) continue
|
||||
const sample = byChart.get(cfg.chart)
|
||||
if (!sample) continue
|
||||
const value = sample.values[cfg.dimension]
|
||||
if (value == null) continue
|
||||
|
||||
let warn = cfg.warn
|
||||
let crit = cfg.crit
|
||||
if (cfg._dynamicLoad) {
|
||||
warn = this.cpuCount * 1.5
|
||||
crit = this.cpuCount * 3
|
||||
}
|
||||
|
||||
const op = cfg.comparator || '>'
|
||||
let severity = null
|
||||
let threshold = null
|
||||
if (compare(op, value, crit)) {
|
||||
severity = 'critical'
|
||||
threshold = crit
|
||||
} else if (compare(op, value, warn)) {
|
||||
severity = 'warning'
|
||||
threshold = warn
|
||||
}
|
||||
|
||||
const prev = this.status.get(cfg.id) || 'CLEAR'
|
||||
const next = severity === 'critical' ? 'CRITICAL' : severity === 'warning' ? 'WARNING' : 'CLEAR'
|
||||
|
||||
if (next !== prev) {
|
||||
this.status.set(cfg.id, next)
|
||||
if (next === 'CLEAR') {
|
||||
const cleared = normalizeAnomaly({
|
||||
id: `${cfg.id}:${sample.ts}`,
|
||||
chart: cfg.chart,
|
||||
context: sample.context,
|
||||
dimension: cfg.dimension,
|
||||
severity: prev === 'CRITICAL' ? 'critical' : 'warning',
|
||||
score: 0,
|
||||
value,
|
||||
threshold: threshold ?? warn ?? crit ?? 0,
|
||||
comparator: op,
|
||||
message: `${cfg.info || cfg.id} cleared`,
|
||||
ts: sample.ts,
|
||||
cleared: true,
|
||||
})
|
||||
this._push(cleared)
|
||||
fired.push(cleared)
|
||||
} else if (severity) {
|
||||
const score = severity === 'critical' ? 1 : 0.6
|
||||
const ev = normalizeAnomaly({
|
||||
id: `${cfg.id}:${sample.ts}`,
|
||||
chart: cfg.chart,
|
||||
context: sample.context,
|
||||
dimension: cfg.dimension,
|
||||
severity,
|
||||
score,
|
||||
value,
|
||||
threshold,
|
||||
comparator: op,
|
||||
message: `${cfg.info || cfg.id}: ${cfg.dimension}=${round2(value)} ${op} ${threshold}`,
|
||||
ts: sample.ts,
|
||||
})
|
||||
this._push(ev)
|
||||
fired.push(ev)
|
||||
this.emit('anomaly', ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fired
|
||||
}
|
||||
|
||||
/** @param {AnomalyEvent} ev */
|
||||
_push(ev) {
|
||||
this.recent.push(ev)
|
||||
if (this.recent.length > this.recentMax) {
|
||||
this.recent.splice(0, this.recent.length - this.recentMax)
|
||||
}
|
||||
}
|
||||
|
||||
getHealth() {
|
||||
let critical = 0
|
||||
let warning = 0
|
||||
const checks = []
|
||||
for (const [id, st] of this.status) {
|
||||
const cfg = this.configs.get(id)
|
||||
checks.push({
|
||||
id,
|
||||
ok: st === 'CLEAR',
|
||||
detail: `${cfg?.info || id}: ${st}`,
|
||||
})
|
||||
if (st === 'CRITICAL') critical++
|
||||
else if (st === 'WARNING') warning++
|
||||
}
|
||||
const status = critical ? 'critical' : warning ? 'degraded' : 'ok'
|
||||
const score = critical ? 0.2 : warning ? 0.7 : 1
|
||||
return { status, score, checks, ts: Date.now() }
|
||||
}
|
||||
}
|
||||
|
||||
function round2(n) {
|
||||
return Math.round(Number(n) * 100) / 100
|
||||
}
|
||||
|
||||
/** @type {AnomalyEngine|null} */
|
||||
let singleton = null
|
||||
|
||||
export function getAnomalyEngine(cpuCount) {
|
||||
if (!singleton) singleton = new AnomalyEngine({ cpuCount })
|
||||
else if (cpuCount) singleton.cpuCount = cpuCount
|
||||
return singleton
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Lightweight system metrics collector (1s target).
|
||||
*
|
||||
* Uses Node `os` + platform-specific best-effort reads (/proc on Linux).
|
||||
* Designed for low overhead — no shelling out on the hot path.
|
||||
*/
|
||||
import os from 'os'
|
||||
import fs from 'fs'
|
||||
import { EventEmitter } from 'events'
|
||||
import { SAMPLE_INTERVAL_MS, CHART_DEFS } from '../../shared/metrics.js'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
const log = logger.child('collector')
|
||||
|
||||
function bytesToMiB(n) {
|
||||
return n / (1024 * 1024)
|
||||
}
|
||||
|
||||
function readProc(path) {
|
||||
try {
|
||||
return fs.readFileSync(path, 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseMeminfo() {
|
||||
const raw = readProc('/proc/meminfo')
|
||||
if (!raw) return null
|
||||
/** @type {Record<string, number>} */
|
||||
const out = {}
|
||||
for (const line of raw.split('\n')) {
|
||||
const m = line.match(/^(\w+):\s+(\d+)/)
|
||||
if (m) out[m[1]] = Number(m[2]) * 1024 // kB → bytes
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function parseLoadavg() {
|
||||
const raw = readProc('/proc/loadavg')
|
||||
if (!raw) {
|
||||
const l = os.loadavg()
|
||||
return { load1: l[0], load5: l[1], load15: l[2], running: 0, total: 0 }
|
||||
}
|
||||
const parts = raw.trim().split(/\s+/)
|
||||
const [running, total] = (parts[3] || '0/0').split('/').map(Number)
|
||||
return {
|
||||
load1: Number(parts[0]),
|
||||
load5: Number(parts[1]),
|
||||
load15: Number(parts[2]),
|
||||
running: running || 0,
|
||||
total: total || 0,
|
||||
}
|
||||
}
|
||||
|
||||
function cpuTimes() {
|
||||
const cpus = os.cpus()
|
||||
const agg = { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 }
|
||||
for (const c of cpus) {
|
||||
agg.user += c.times.user
|
||||
agg.nice += c.times.nice
|
||||
agg.sys += c.times.sys
|
||||
agg.idle += c.times.idle
|
||||
agg.irq += c.times.irq
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
function netCounters() {
|
||||
const ni = os.networkInterfaces()
|
||||
// os does not expose byte counters — use /proc/net/dev on Linux
|
||||
const raw = readProc('/proc/net/dev')
|
||||
if (!raw) {
|
||||
return { rx: 0, tx: 0, ifaces: Object.keys(ni || {}).length }
|
||||
}
|
||||
let rx = 0
|
||||
let tx = 0
|
||||
for (const line of raw.split('\n').slice(2)) {
|
||||
const parts = line.trim().split(/\s+/)
|
||||
if (parts.length < 10) continue
|
||||
const name = parts[0].replace(':', '')
|
||||
if (name === 'lo') continue
|
||||
rx += Number(parts[1]) || 0
|
||||
tx += Number(parts[9]) || 0
|
||||
}
|
||||
return { rx, tx, ifaces: Object.keys(ni || {}).length }
|
||||
}
|
||||
|
||||
function diskCounters() {
|
||||
const raw = readProc('/proc/diskstats')
|
||||
if (!raw) return { reads: 0, writes: 0 }
|
||||
let reads = 0
|
||||
let writes = 0
|
||||
for (const line of raw.split('\n')) {
|
||||
const p = line.trim().split(/\s+/)
|
||||
if (p.length < 14) continue
|
||||
const name = p[2]
|
||||
// skip partitions (sda1) and ram/loop
|
||||
if (/^(loop|ram|fd)/.test(name)) continue
|
||||
if (/\d+$/.test(name) && !/^nvme/.test(name)) continue
|
||||
if (/^nvme.+p\d+$/.test(name)) continue
|
||||
reads += (Number(p[5]) || 0) * 512 // sectors → bytes
|
||||
writes += (Number(p[9]) || 0) * 512
|
||||
}
|
||||
return { reads, writes }
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute rate from two counter snapshots.
|
||||
* @param {number} prev
|
||||
* @param {number} cur
|
||||
* @param {number} dtSec
|
||||
*/
|
||||
function rate(prev, cur, dtSec) {
|
||||
if (dtSec <= 0 || cur < prev) return 0
|
||||
return (cur - prev) / dtSec
|
||||
}
|
||||
|
||||
export class MetricsCollector extends EventEmitter {
|
||||
/**
|
||||
* @param {{ intervalMs?: number }} [opts]
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
super()
|
||||
this.intervalMs = opts.intervalMs ?? Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS
|
||||
this.timer = null
|
||||
this.running = false
|
||||
this.sampleCount = 0
|
||||
this.lastCpu = null
|
||||
this.lastNet = null
|
||||
this.lastDisk = null
|
||||
this.lastTs = 0
|
||||
/** @type {Map<string, Record<string, number|null>>} */
|
||||
this.latest = new Map()
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.running) return
|
||||
this.running = true
|
||||
this._tick()
|
||||
this.timer = setInterval(() => this._tick(), this.intervalMs)
|
||||
if (this.timer.unref) this.timer.unref()
|
||||
log.info('Collector started', { intervalMs: this.intervalMs })
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false
|
||||
if (this.timer) clearInterval(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
|
||||
_tick() {
|
||||
try {
|
||||
const ts = Date.now()
|
||||
const dtSec = this.lastTs ? (ts - this.lastTs) / 1000 : this.intervalMs / 1000
|
||||
this.lastTs = ts
|
||||
|
||||
/** @type {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} */
|
||||
const batch = []
|
||||
|
||||
// CPU %
|
||||
const cpu = cpuTimes()
|
||||
let cpuVals = {
|
||||
user: 0,
|
||||
system: 0,
|
||||
nice: 0,
|
||||
iowait: 0,
|
||||
irq: 0,
|
||||
softirq: 0,
|
||||
idle: 100,
|
||||
}
|
||||
if (this.lastCpu) {
|
||||
const dUser = cpu.user - this.lastCpu.user
|
||||
const dNice = cpu.nice - this.lastCpu.nice
|
||||
const dSys = cpu.sys - this.lastCpu.sys
|
||||
const dIdle = cpu.idle - this.lastCpu.idle
|
||||
const dIrq = cpu.irq - this.lastCpu.irq
|
||||
const total = dUser + dNice + dSys + dIdle + dIrq
|
||||
if (total > 0) {
|
||||
cpuVals = {
|
||||
user: (dUser / total) * 100,
|
||||
system: (dSys / total) * 100,
|
||||
nice: (dNice / total) * 100,
|
||||
iowait: 0,
|
||||
irq: (dIrq / total) * 100,
|
||||
softirq: 0,
|
||||
idle: (dIdle / total) * 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
this.lastCpu = cpu
|
||||
batch.push({ chart: 'system.cpu', context: 'system.cpu', ts, values: cpuVals })
|
||||
|
||||
// RAM
|
||||
const mem = parseMeminfo()
|
||||
const total = os.totalmem()
|
||||
const free = os.freemem()
|
||||
let used = total - free
|
||||
let cached = 0
|
||||
let buffers = 0
|
||||
let avail = free
|
||||
if (mem) {
|
||||
const totalB = mem.MemTotal || total
|
||||
const freeB = mem.MemFree || free
|
||||
buffers = mem.Buffers || 0
|
||||
cached = (mem.Cached || 0) + (mem.SReclaimable || 0)
|
||||
avail = mem.MemAvailable || freeB
|
||||
used = Math.max(0, totalB - freeB - buffers - cached)
|
||||
batch.push({
|
||||
chart: 'system.ram',
|
||||
context: 'system.ram',
|
||||
ts,
|
||||
values: {
|
||||
used: bytesToMiB(used),
|
||||
cached: bytesToMiB(cached),
|
||||
buffers: bytesToMiB(buffers),
|
||||
free: bytesToMiB(freeB),
|
||||
},
|
||||
})
|
||||
batch.push({
|
||||
chart: 'mem.available',
|
||||
context: 'mem.available',
|
||||
ts,
|
||||
values: { avail: bytesToMiB(avail) },
|
||||
})
|
||||
} else {
|
||||
batch.push({
|
||||
chart: 'system.ram',
|
||||
context: 'system.ram',
|
||||
ts,
|
||||
values: {
|
||||
used: bytesToMiB(used),
|
||||
cached: 0,
|
||||
buffers: 0,
|
||||
free: bytesToMiB(free),
|
||||
},
|
||||
})
|
||||
batch.push({
|
||||
chart: 'mem.available',
|
||||
context: 'mem.available',
|
||||
ts,
|
||||
values: { avail: bytesToMiB(free) },
|
||||
})
|
||||
}
|
||||
|
||||
const load = parseLoadavg()
|
||||
batch.push({
|
||||
chart: 'system.load',
|
||||
context: 'system.load',
|
||||
ts,
|
||||
values: { load1: load.load1, load5: load.load5, load15: load.load15 },
|
||||
})
|
||||
batch.push({
|
||||
chart: 'system.processes',
|
||||
context: 'system.processes',
|
||||
ts,
|
||||
values: {
|
||||
running: load.running,
|
||||
blocked: 0,
|
||||
total: load.total || Object.keys(process || {}).length,
|
||||
},
|
||||
})
|
||||
|
||||
const net = netCounters()
|
||||
let rxRate = 0
|
||||
let txRate = 0
|
||||
if (this.lastNet) {
|
||||
// bytes/s → kilobits/s
|
||||
rxRate = (rate(this.lastNet.rx, net.rx, dtSec) * 8) / 1000
|
||||
txRate = (rate(this.lastNet.tx, net.tx, dtSec) * 8) / 1000
|
||||
}
|
||||
this.lastNet = net
|
||||
batch.push({
|
||||
chart: 'system.net',
|
||||
context: 'system.net',
|
||||
ts,
|
||||
values: { received: rxRate, sent: txRate },
|
||||
})
|
||||
|
||||
const disk = diskCounters()
|
||||
let readRate = 0
|
||||
let writeRate = 0
|
||||
if (this.lastDisk) {
|
||||
readRate = rate(this.lastDisk.reads, disk.reads, dtSec) / 1024 // KiB/s
|
||||
writeRate = rate(this.lastDisk.writes, disk.writes, dtSec) / 1024
|
||||
}
|
||||
this.lastDisk = disk
|
||||
batch.push({
|
||||
chart: 'system.io',
|
||||
context: 'system.io',
|
||||
ts,
|
||||
values: { reads: readRate, writes: writeRate },
|
||||
})
|
||||
|
||||
batch.push({
|
||||
chart: 'system.uptime',
|
||||
context: 'system.uptime',
|
||||
ts,
|
||||
values: { uptime: os.uptime() },
|
||||
})
|
||||
|
||||
for (const s of batch) {
|
||||
this.latest.set(s.chart, s.values)
|
||||
}
|
||||
this.sampleCount++
|
||||
this.emit('samples', batch)
|
||||
} catch (err) {
|
||||
log.error('Collector tick failed', { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
getLatest() {
|
||||
/** @type {Record<string, Record<string, number|null>>} */
|
||||
const out = {}
|
||||
for (const [k, v] of this.latest) out[k] = v
|
||||
return out
|
||||
}
|
||||
|
||||
getNodeInfo(publicKeyHex, agentVersion) {
|
||||
return {
|
||||
nodeId: publicKeyHex?.slice(0, 16) || os.hostname(),
|
||||
hostname: os.hostname(),
|
||||
publicKeyHex: publicKeyHex || null,
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
release: os.release(),
|
||||
cpus: os.cpus().length,
|
||||
totalMemMiB: bytesToMiB(os.totalmem()),
|
||||
agentVersion,
|
||||
startedAt: Date.now() - Math.floor(process.uptime() * 1000),
|
||||
charts: CHART_DEFS.map((c) => c.id),
|
||||
sampleIntervalMs: this.intervalMs,
|
||||
sampleCount: this.sampleCount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {MetricsCollector|null} */
|
||||
let singleton = null
|
||||
|
||||
export function getCollector() {
|
||||
if (!singleton) singleton = new MetricsCollector()
|
||||
return singleton
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Lightweight job tray for on-demand agent operations.
|
||||
*/
|
||||
import { EventEmitter } from 'events'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { Pushes } from '../../shared/protocol.js'
|
||||
import { peers } from '../core/peer-registry.js'
|
||||
import { getCollector } from './collector.js'
|
||||
import { getStore } from './store.js'
|
||||
|
||||
const JOB_HANDLERS = {
|
||||
collectOnce: async () => {
|
||||
const c = getCollector()
|
||||
c._tick()
|
||||
return { ok: true, sampleCount: c.sampleCount }
|
||||
},
|
||||
snapshot: async () => {
|
||||
return { ok: true, latest: getStore().latestValues() }
|
||||
},
|
||||
gcBuffers: async () => {
|
||||
// ring buffers self-trim; placeholder for future disk GC
|
||||
return { ok: true }
|
||||
},
|
||||
}
|
||||
|
||||
export class JobService extends EventEmitter {
|
||||
constructor() {
|
||||
super()
|
||||
/** @type {Map<string, import('../../shared/data-model.js').JobRecord>} */
|
||||
this.jobs = new Map()
|
||||
}
|
||||
|
||||
list() {
|
||||
return [...this.jobs.values()].sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {object} [args]
|
||||
*/
|
||||
async run(name, args = {}) {
|
||||
const handler = JOB_HANDLERS[name]
|
||||
if (!handler) {
|
||||
return { success: false, error: `unknown job: ${name}`, known: Object.keys(JOB_HANDLERS) }
|
||||
}
|
||||
const id = randomUUID()
|
||||
/** @type {import('../../shared/data-model.js').JobRecord} */
|
||||
const rec = {
|
||||
id,
|
||||
name,
|
||||
status: 'running',
|
||||
startedAt: Date.now(),
|
||||
finishedAt: null,
|
||||
}
|
||||
this.jobs.set(id, rec)
|
||||
this._push(rec)
|
||||
try {
|
||||
rec.result = await handler(args)
|
||||
rec.status = 'done'
|
||||
rec.finishedAt = Date.now()
|
||||
} catch (err) {
|
||||
rec.status = 'failed'
|
||||
rec.error = err.message
|
||||
rec.finishedAt = Date.now()
|
||||
}
|
||||
this._push(rec)
|
||||
return { success: rec.status === 'done', job: rec }
|
||||
}
|
||||
|
||||
cancel(id) {
|
||||
const rec = this.jobs.get(id)
|
||||
if (!rec) return { success: false, error: 'unknown job' }
|
||||
if (rec.status === 'running') {
|
||||
rec.status = 'cancelled'
|
||||
rec.finishedAt = Date.now()
|
||||
this._push(rec)
|
||||
}
|
||||
return { success: true, job: rec }
|
||||
}
|
||||
|
||||
_push(rec) {
|
||||
for (const session of peers.list()) {
|
||||
if (session.closed) continue
|
||||
try {
|
||||
session.push(Pushes.job, rec)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {JobService|null} */
|
||||
let singleton = null
|
||||
|
||||
export function getJobs() {
|
||||
if (!singleton) singleton = new JobService()
|
||||
return singleton
|
||||
}
|
||||
|
||||
export function knownJobNames() {
|
||||
return Object.keys(JOB_HANDLERS)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* In-memory tiered metric ring buffers.
|
||||
*
|
||||
* Tier 0: high-res (1s) short retention
|
||||
* Tier 1: downsampled (avg over window) longer retention
|
||||
*
|
||||
* Future: Hypercore / disk-backed persistence.
|
||||
*/
|
||||
import { SAMPLE_INTERVAL_MS, CHART_BY_ID, chartSummary } from '../../shared/metrics.js'
|
||||
|
||||
function envInt(name, fallback) {
|
||||
const n = Number(process.env[name])
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback
|
||||
}
|
||||
|
||||
export class MetricStore {
|
||||
constructor() {
|
||||
this.tier0Max = envInt('PEARDATA_TIER0_POINTS', 3600) // 1h @ 1s
|
||||
this.tier1Max = envInt('PEARDATA_TIER1_POINTS', 1440) // 24h @ 1m
|
||||
this.tier1Every = envInt('PEARDATA_TIER1_EVERY', 60) // downsample every N samples
|
||||
/** @type {Map<string, { points: Array<{ts:number, values: Record<string, number|null>}>, tier1: Array<{ts:number, values: Record<string, number|null>}>, acc: object|null, accCount: number }>} */
|
||||
this.series = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{ chart: string, context: string, ts: number, values: Record<string, number|null> }>} batch
|
||||
*/
|
||||
ingest(batch) {
|
||||
for (const s of batch) {
|
||||
let entry = this.series.get(s.chart)
|
||||
if (!entry) {
|
||||
entry = { points: [], tier1: [], acc: null, accCount: 0 }
|
||||
this.series.set(s.chart, entry)
|
||||
}
|
||||
entry.points.push({ ts: s.ts, values: s.values })
|
||||
if (entry.points.length > this.tier0Max) {
|
||||
entry.points.splice(0, entry.points.length - this.tier0Max)
|
||||
}
|
||||
|
||||
// accumulate for tier1
|
||||
if (!entry.acc) {
|
||||
entry.acc = { ...s.values }
|
||||
entry.accCount = 1
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(s.values)) {
|
||||
if (v == null || Number.isNaN(v)) continue
|
||||
entry.acc[k] = (entry.acc[k] || 0) + v
|
||||
}
|
||||
entry.accCount++
|
||||
}
|
||||
if (entry.accCount >= this.tier1Every) {
|
||||
/** @type {Record<string, number|null>} */
|
||||
const avg = {}
|
||||
for (const [k, v] of Object.entries(entry.acc)) {
|
||||
avg[k] = entry.accCount ? v / entry.accCount : null
|
||||
}
|
||||
entry.tier1.push({ ts: s.ts, values: avg })
|
||||
if (entry.tier1.length > this.tier1Max) {
|
||||
entry.tier1.splice(0, entry.tier1.length - this.tier1Max)
|
||||
}
|
||||
entry.acc = null
|
||||
entry.accCount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} chart
|
||||
*/
|
||||
getMeta(chart) {
|
||||
const def = CHART_BY_ID.get(chart)
|
||||
const entry = this.series.get(chart)
|
||||
const first = entry?.points[0]?.ts
|
||||
const last = entry?.points[entry.points.length - 1]?.ts
|
||||
if (!def) return null
|
||||
return chartSummary(def, {
|
||||
firstEntry: first ? Math.floor(first / 1000) : 0,
|
||||
lastEntry: last ? Math.floor(last / 1000) : 0,
|
||||
updateEvery: SAMPLE_INTERVAL_MS / 1000,
|
||||
})
|
||||
}
|
||||
|
||||
listChartSummaries() {
|
||||
/** @type {Record<string, any>} */
|
||||
const charts = {}
|
||||
for (const id of CHART_BY_ID.keys()) {
|
||||
const meta = this.getMeta(id)
|
||||
if (meta) charts[id] = meta
|
||||
}
|
||||
return charts
|
||||
}
|
||||
|
||||
/**
|
||||
* Query points for a chart (Netdata-like after/before/points).
|
||||
*
|
||||
* @param {{ chart: string, after?: number, before?: number, points?: number, group?: string, tier?: number }} opts
|
||||
*/
|
||||
query(opts) {
|
||||
const chart = opts.chart
|
||||
const entry = this.series.get(chart)
|
||||
const def = CHART_BY_ID.get(chart)
|
||||
if (!def) {
|
||||
return { error: 'unknown chart', chart }
|
||||
}
|
||||
|
||||
const useTier1 = opts.tier === 1
|
||||
const src = entry ? (useTier1 ? entry.tier1 : entry.points) : []
|
||||
const nowSec = Math.floor(Date.now() / 1000)
|
||||
let before = opts.before == null || opts.before === 0 ? nowSec : Number(opts.before)
|
||||
let after = opts.after == null ? -Math.min(opts.points || 60, src.length || 60) : Number(opts.after)
|
||||
|
||||
if (before <= 0) before = nowSec + before
|
||||
if (after <= 0) after = before + after // relative seconds
|
||||
|
||||
const afterMs = after * 1000
|
||||
const beforeMs = before * 1000
|
||||
|
||||
let windowed = src.filter((p) => p.ts >= afterMs && p.ts <= beforeMs)
|
||||
if (!windowed.length && src.length) {
|
||||
// fall back to latest N
|
||||
const n = Math.min(opts.points || 60, src.length)
|
||||
windowed = src.slice(-n)
|
||||
}
|
||||
|
||||
const want = Math.min(opts.points || windowed.length || 60, 10_000)
|
||||
const sampled = downsample(windowed, want, opts.group || 'average', def.dimensions.map((d) => d.id))
|
||||
|
||||
const labels = ['time', ...def.dimensions.map((d) => d.id)]
|
||||
const data = sampled.map((p) => {
|
||||
const row = [Math.floor(p.ts / 1000)]
|
||||
for (const dim of def.dimensions) {
|
||||
const v = p.values[dim.id]
|
||||
row.push(v == null || Number.isNaN(v) ? null : round4(v))
|
||||
}
|
||||
return row
|
||||
})
|
||||
|
||||
return {
|
||||
chart,
|
||||
context: def.context,
|
||||
labels,
|
||||
data,
|
||||
view_update_every: SAMPLE_INTERVAL_MS / 1000,
|
||||
after: after,
|
||||
before: before,
|
||||
points: data.length,
|
||||
format: 'json',
|
||||
}
|
||||
}
|
||||
|
||||
latestValues() {
|
||||
/** @type {Record<string, { ts: number, values: Record<string, number|null> }>} */
|
||||
const out = {}
|
||||
for (const [chart, entry] of this.series) {
|
||||
const last = entry.points[entry.points.length - 1]
|
||||
if (last) out[chart] = last
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{ts:number, values: object}>} points
|
||||
* @param {number} want
|
||||
* @param {string} group
|
||||
* @param {string[]} dims
|
||||
*/
|
||||
function downsample(points, want, group, dims) {
|
||||
if (points.length <= want) return points
|
||||
const bucketSize = points.length / want
|
||||
/** @type {typeof points} */
|
||||
const out = []
|
||||
for (let i = 0; i < want; i++) {
|
||||
const start = Math.floor(i * bucketSize)
|
||||
const end = Math.floor((i + 1) * bucketSize)
|
||||
const slice = points.slice(start, Math.max(start + 1, end))
|
||||
const acc = {}
|
||||
for (const d of dims) acc[d] = []
|
||||
for (const p of slice) {
|
||||
for (const d of dims) {
|
||||
const v = p.values[d]
|
||||
if (v != null && !Number.isNaN(v)) acc[d].push(v)
|
||||
}
|
||||
}
|
||||
/** @type {Record<string, number|null>} */
|
||||
const values = {}
|
||||
for (const d of dims) {
|
||||
values[d] = aggregate(acc[d], group)
|
||||
}
|
||||
out.push({ ts: slice[slice.length - 1].ts, values })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function aggregate(arr, group) {
|
||||
if (!arr.length) return null
|
||||
if (group === 'min') return Math.min(...arr)
|
||||
if (group === 'max') return Math.max(...arr)
|
||||
if (group === 'sum') return arr.reduce((a, b) => a + b, 0)
|
||||
// average default
|
||||
return arr.reduce((a, b) => a + b, 0) / arr.length
|
||||
}
|
||||
|
||||
function round4(n) {
|
||||
return Math.round(n * 10000) / 10000
|
||||
}
|
||||
|
||||
/** @type {MetricStore|null} */
|
||||
let singleton = null
|
||||
|
||||
export function getStore() {
|
||||
if (!singleton) singleton = new MetricStore()
|
||||
return singleton
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Per-session metric / anomaly push subscriptions.
|
||||
*/
|
||||
import { Pushes } from '../../shared/protocol.js'
|
||||
import { peers } from '../core/peer-registry.js'
|
||||
|
||||
/**
|
||||
* @param {import('../rpc/session.js').PeerSession} session
|
||||
* @param {{ charts: string[], intervalMs: number }} opts
|
||||
*/
|
||||
export function subscribeMetrics(session, opts) {
|
||||
const charts = opts.charts?.includes('*') ? ['*'] : opts.charts || ['*']
|
||||
session.state.set('metricSub', {
|
||||
charts,
|
||||
intervalMs: opts.intervalMs || 1000,
|
||||
})
|
||||
return { success: true, charts, intervalMs: opts.intervalMs || 1000 }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../rpc/session.js').PeerSession} session
|
||||
*/
|
||||
export function unsubscribeMetrics(session) {
|
||||
session.state.delete('metricSub')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../rpc/session.js').PeerSession} session
|
||||
*/
|
||||
export function subscribeAnomalies(session) {
|
||||
session.state.set('anomalySub', true)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../rpc/session.js').PeerSession} session
|
||||
*/
|
||||
export function unsubscribeAnomalies(session) {
|
||||
session.state.delete('anomalySub')
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fan-out a metric batch to subscribed peers (throttled per session preference).
|
||||
* @param {Array<{ chart: string, context: string, ts: number, values: object }>} batch
|
||||
*/
|
||||
export function broadcastMetrics(batch) {
|
||||
for (const session of peers.list()) {
|
||||
const sub = session.state.get('metricSub')
|
||||
if (!sub || session.closed) continue
|
||||
const filtered =
|
||||
sub.charts.includes('*')
|
||||
? batch
|
||||
: batch.filter((s) => sub.charts.includes(s.chart) || sub.charts.includes(s.context))
|
||||
if (!filtered.length) continue
|
||||
|
||||
// simple throttle
|
||||
const last = session.state.get('metricSubLast') || 0
|
||||
const now = Date.now()
|
||||
if (now - last < (sub.intervalMs || 1000) - 50) continue
|
||||
session.state.set('metricSubLast', now)
|
||||
|
||||
try {
|
||||
session.push(Pushes.metrics, { samples: filtered })
|
||||
} catch {
|
||||
// ignore dead peers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} anomaly
|
||||
*/
|
||||
export function broadcastAnomaly(anomaly) {
|
||||
for (const session of peers.list()) {
|
||||
if (!session.state.get('anomalySub') || session.closed) continue
|
||||
try {
|
||||
session.push(Pushes.anomaly, anomaly)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} payload
|
||||
*/
|
||||
export function broadcastHealth(payload) {
|
||||
for (const session of peers.list()) {
|
||||
if (session.closed) continue
|
||||
try {
|
||||
session.push(Pushes.health, payload)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Tiny structured logger (JSON lines optional).
|
||||
*/
|
||||
|
||||
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 }
|
||||
const minLevel =
|
||||
LEVELS[String(process.env.LOG_LEVEL || 'info').toLowerCase()] ?? LEVELS.info
|
||||
|
||||
function emit(level, scope, message, fields) {
|
||||
if ((LEVELS[level] ?? 99) < minLevel) return
|
||||
const line = {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
scope,
|
||||
msg: message,
|
||||
...(fields && typeof fields === 'object' ? fields : {}),
|
||||
}
|
||||
const text = process.env.LOG_JSON === '1' ? JSON.stringify(line) : formatPretty(line)
|
||||
if (level === 'error') console.error(text)
|
||||
else if (level === 'warn') console.warn(text)
|
||||
else console.log(text)
|
||||
}
|
||||
|
||||
function formatPretty(line) {
|
||||
const extra = { ...line }
|
||||
delete extra.ts
|
||||
delete extra.level
|
||||
delete extra.scope
|
||||
delete extra.msg
|
||||
const keys = Object.keys(extra)
|
||||
const tail = keys.length ? ' ' + JSON.stringify(extra) : ''
|
||||
return `${line.ts} [${line.level}] ${line.scope}: ${line.msg}${tail}`
|
||||
}
|
||||
|
||||
function child(scope) {
|
||||
return {
|
||||
debug: (msg, fields) => emit('debug', scope, msg, fields),
|
||||
info: (msg, fields) => emit('info', scope, msg, fields),
|
||||
warn: (msg, fields) => emit('warn', scope, msg, fields),
|
||||
error: (msg, fields) => emit('error', scope, msg, fields),
|
||||
child: (sub) => child(`${scope}:${sub}`),
|
||||
banner(fields = {}) {
|
||||
const bar = '═'.repeat(56)
|
||||
console.log(bar)
|
||||
console.log(` ${fields.title || 'Pear App Server'}`)
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (k === 'title') continue
|
||||
console.log(` ${k}: ${v}`)
|
||||
}
|
||||
console.log(bar)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const logger = child('app')
|
||||
export default logger
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Simple per-peer sliding window rate limiter.
|
||||
*/
|
||||
|
||||
const WINDOW_MS = 60_000
|
||||
const DEFAULT_RPM = Number(process.env.PEARDATA_RATE_LIMIT_RPM) || 120
|
||||
|
||||
/** @type {Map<string, number[]>} */
|
||||
const hits = new Map()
|
||||
|
||||
const STREAM_METHODS = new Set([
|
||||
'ping',
|
||||
'queryData',
|
||||
'subscribeMetrics',
|
||||
'unsubscribeMetrics',
|
||||
'getAllMetrics',
|
||||
])
|
||||
|
||||
/**
|
||||
* @param {{ id: string }} session
|
||||
* @param {string} method
|
||||
*/
|
||||
export function isAllowed(session, method) {
|
||||
if (STREAM_METHODS.has(method)) return true
|
||||
const id = session?.id || 'anon'
|
||||
const now = Date.now()
|
||||
let list = hits.get(id)
|
||||
if (!list) {
|
||||
list = []
|
||||
hits.set(id, list)
|
||||
}
|
||||
const cutoff = now - WINDOW_MS
|
||||
while (list.length && list[0] < cutoff) list.shift()
|
||||
if (list.length >= DEFAULT_RPM) return false
|
||||
list.push(now)
|
||||
return true
|
||||
}
|
||||
|
||||
export function isStreamMethod(method) {
|
||||
return STREAM_METHODS.has(method)
|
||||
}
|
||||
|
||||
export default { isAllowed, isStreamMethod }
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* HMAC capability grants + admin seed proof for handshake auth.
|
||||
*
|
||||
* Pure helpers shared by server and client. Never logs secrets.
|
||||
*
|
||||
* Capability token format:
|
||||
* base64url(JSON payload) + "." + base64url(HMAC-SHA256(macKey, payloadBytes))
|
||||
*
|
||||
* Admin proof (single-round):
|
||||
* mac = HMAC-SHA256(macKey, "peardata-admin-v1" || nonce || peerId || serverPubKey)
|
||||
*
|
||||
* Invite envelope:
|
||||
* pd1.<base64url JSON { publicKeyHex, capability, role, ... }>
|
||||
*/
|
||||
import crypto from 'crypto'
|
||||
import { Roles } from './protocol.js'
|
||||
|
||||
const SALT = Buffer.from('peardata-hmac-v1', 'utf8')
|
||||
const INFO_CAPABILITY = Buffer.from('capability', 'utf8')
|
||||
const ADMIN_PREFIX = Buffer.from('peardata-admin-v1', 'utf8')
|
||||
const VALID_ROLES = new Set([Roles.viewer, Roles.operator, Roles.admin])
|
||||
|
||||
export const INVITE_PREFIX = 'pd1.'
|
||||
|
||||
/**
|
||||
* @param {string|Uint8Array|Buffer} seedHexOrBuf
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
export function deriveMacKey(seedHexOrBuf) {
|
||||
const ikm = toSeedBuffer(seedHexOrBuf)
|
||||
if (typeof crypto.hkdfSync === 'function') {
|
||||
return Buffer.from(crypto.hkdfSync('sha256', ikm, SALT, INFO_CAPABILITY, 32))
|
||||
}
|
||||
const prk = crypto.createHmac('sha256', SALT).update(ikm).digest()
|
||||
const info = Buffer.concat([INFO_CAPABILITY, Buffer.from([0x01])])
|
||||
return crypto.createHmac('sha256', prk).update(info).digest()
|
||||
}
|
||||
|
||||
function toSeedBuffer(seedHexOrBuf) {
|
||||
if (Buffer.isBuffer(seedHexOrBuf) || seedHexOrBuf instanceof Uint8Array) {
|
||||
const buf = Buffer.from(seedHexOrBuf)
|
||||
if (buf.length !== 32) throw new Error('Seed must be 32 bytes')
|
||||
return buf
|
||||
}
|
||||
const hex = String(seedHexOrBuf || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!/^[0-9a-f]{64}$/.test(hex)) {
|
||||
throw new Error('Seed must be 64 hex characters (32 bytes)')
|
||||
}
|
||||
return Buffer.from(hex, 'hex')
|
||||
}
|
||||
|
||||
export function b64url(buf) {
|
||||
return Buffer.from(buf)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export function b64urlDecode(s) {
|
||||
const str = String(s || '').replace(/-/g, '+').replace(/_/g, '/')
|
||||
const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4))
|
||||
return Buffer.from(str + pad, 'base64')
|
||||
}
|
||||
|
||||
export function canonicalizePayload(payload) {
|
||||
const ordered = {
|
||||
v: payload.v,
|
||||
role: payload.role,
|
||||
peerId: payload.peerId ?? null,
|
||||
exp: payload.exp,
|
||||
jti: payload.jti,
|
||||
iat: payload.iat,
|
||||
}
|
||||
return Buffer.from(JSON.stringify(ordered), 'utf8')
|
||||
}
|
||||
|
||||
function resolveMacKey(macKeyOrSeed) {
|
||||
if (Buffer.isBuffer(macKeyOrSeed) || macKeyOrSeed instanceof Uint8Array) {
|
||||
const buf = Buffer.from(macKeyOrSeed)
|
||||
if (buf.length === 32) return buf
|
||||
}
|
||||
if (typeof macKeyOrSeed === 'string' && /^[0-9a-fA-F]{64}$/.test(macKeyOrSeed.trim())) {
|
||||
return deriveMacKey(macKeyOrSeed.trim())
|
||||
}
|
||||
if (Buffer.isBuffer(macKeyOrSeed) || macKeyOrSeed instanceof Uint8Array) {
|
||||
return deriveMacKey(macKeyOrSeed)
|
||||
}
|
||||
throw new Error('Invalid mac key or seed')
|
||||
}
|
||||
|
||||
export function safeEqual(a, b) {
|
||||
if (!Buffer.isBuffer(a)) a = Buffer.from(a)
|
||||
if (!Buffer.isBuffer(b)) b = Buffer.from(b)
|
||||
if (a.length !== b.length) return false
|
||||
return crypto.timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer|string} macKeyOrSeed
|
||||
* @param {{ role?: string, ttlMs?: number|null, peerId?: string|null, jti?: string, forever?: boolean }} opts
|
||||
*/
|
||||
export function signCapability(macKeyOrSeed, opts = {}) {
|
||||
const macKey = resolveMacKey(macKeyOrSeed)
|
||||
const role = String(opts.role || Roles.operator).toLowerCase()
|
||||
if (!VALID_ROLES.has(role)) throw new Error(`Invalid capability role: ${role}`)
|
||||
|
||||
const now = Date.now()
|
||||
const rawTtl = opts.ttlMs
|
||||
let exp = null
|
||||
if (opts.forever === true || rawTtl === 0 || rawTtl === null || rawTtl === undefined) {
|
||||
exp = null
|
||||
} else {
|
||||
const ttlMs = Math.min(
|
||||
Math.max(Number(rawTtl) || 72 * 3600 * 1000, 60_000),
|
||||
100 * 365 * 24 * 3600 * 1000
|
||||
)
|
||||
exp = now + ttlMs
|
||||
}
|
||||
const payload = {
|
||||
v: 1,
|
||||
role,
|
||||
peerId: opts.peerId ? String(opts.peerId).toLowerCase() : null,
|
||||
exp,
|
||||
jti: opts.jti || crypto.randomBytes(16).toString('hex'),
|
||||
iat: now,
|
||||
}
|
||||
const body = canonicalizePayload(payload)
|
||||
const mac = crypto.createHmac('sha256', macKey).update(body).digest()
|
||||
return { token: `${b64url(body)}.${b64url(mac)}`, payload }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer|string} macKeyOrSeed
|
||||
* @param {string} token
|
||||
* @param {{ peerId?: string, now?: number, allowSpentCheck?: (jti: string) => boolean }} [opts]
|
||||
*/
|
||||
export function verifyCapability(macKeyOrSeed, token, opts = {}) {
|
||||
if (!token || typeof token !== 'string') {
|
||||
return { ok: false, error: 'Missing capability token', code: 'CAPABILITY_INVALID' }
|
||||
}
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 2) {
|
||||
return { ok: false, error: 'Malformed capability token', code: 'CAPABILITY_INVALID' }
|
||||
}
|
||||
let body
|
||||
let mac
|
||||
try {
|
||||
body = b64urlDecode(parts[0])
|
||||
mac = b64urlDecode(parts[1])
|
||||
} catch {
|
||||
return { ok: false, error: 'Malformed capability encoding', code: 'CAPABILITY_INVALID' }
|
||||
}
|
||||
if (mac.length !== 32) {
|
||||
return { ok: false, error: 'Invalid capability MAC length', code: 'CAPABILITY_INVALID' }
|
||||
}
|
||||
|
||||
const macKey = resolveMacKey(macKeyOrSeed)
|
||||
const expected = crypto.createHmac('sha256', macKey).update(body).digest()
|
||||
if (!safeEqual(mac, expected)) {
|
||||
return { ok: false, error: 'Capability MAC verification failed', code: 'CAPABILITY_INVALID' }
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(body.toString('utf8'))
|
||||
} catch {
|
||||
return { ok: false, error: 'Capability payload not JSON', code: 'CAPABILITY_INVALID' }
|
||||
}
|
||||
|
||||
if (payload.v !== 1) {
|
||||
return { ok: false, error: 'Unsupported capability version', code: 'CAPABILITY_INVALID' }
|
||||
}
|
||||
if (!VALID_ROLES.has(payload.role)) {
|
||||
return { ok: false, error: 'Invalid capability role', code: 'CAPABILITY_INVALID' }
|
||||
}
|
||||
|
||||
const now = opts.now ?? Date.now()
|
||||
if (payload.exp != null) {
|
||||
if (typeof payload.exp !== 'number' || payload.exp < now) {
|
||||
return { ok: false, error: 'Capability expired', code: 'CAPABILITY_EXPIRED' }
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.peerId) {
|
||||
const want = String(payload.peerId).toLowerCase()
|
||||
const have = String(opts.peerId || '').toLowerCase()
|
||||
if (!have || want !== have) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Capability bound to a different peer identity',
|
||||
code: 'CAPABILITY_PEER_MISMATCH',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof opts.allowSpentCheck === 'function' && !opts.allowSpentCheck(payload.jti)) {
|
||||
return { ok: false, error: 'Capability already used or revoked', code: 'CAPABILITY_SPENT' }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
payload: {
|
||||
v: 1,
|
||||
role: payload.role,
|
||||
peerId: payload.peerId || null,
|
||||
exp: payload.exp,
|
||||
jti: payload.jti,
|
||||
iat: payload.iat,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function hmacAdmin(macKey, nonce, peerId, serverPk) {
|
||||
return crypto
|
||||
.createHmac('sha256', macKey)
|
||||
.update(ADMIN_PREFIX)
|
||||
.update(Buffer.from(nonce, 'utf8'))
|
||||
.update(Buffer.from(peerId, 'utf8'))
|
||||
.update(Buffer.from(serverPk, 'utf8'))
|
||||
.digest()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer|string} macKeyOrSeed
|
||||
* @param {{ nonce?: string, peerId: string, serverPublicKeyHex: string }} opts
|
||||
*/
|
||||
export function createAdminProof(macKeyOrSeed, opts) {
|
||||
const nonce = String(opts.nonce || crypto.randomBytes(16).toString('hex'))
|
||||
if (!/^[0-9a-fA-F]{16,64}$/.test(nonce)) {
|
||||
throw new Error('Admin proof nonce must be 16-64 hex characters')
|
||||
}
|
||||
const peerId = String(opts.peerId || '').toLowerCase()
|
||||
const serverPk = String(opts.serverPublicKeyHex || '').toLowerCase()
|
||||
if (!/^[0-9a-f]{64}$/.test(peerId)) throw new Error('peerId required for admin proof')
|
||||
if (!/^[0-9a-f]{64}$/.test(serverPk)) throw new Error('serverPublicKeyHex required for admin proof')
|
||||
|
||||
const macKey = resolveMacKey(macKeyOrSeed)
|
||||
const mac = hmacAdmin(macKey, nonce, peerId, serverPk)
|
||||
return { nonce: nonce.toLowerCase(), mac: mac.toString('hex') }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer|string} macKeyOrSeed
|
||||
* @param {{ nonce?: string, mac?: string }|null} proof
|
||||
* @param {{ peerId: string, serverPublicKeyHex: string }} ctx
|
||||
*/
|
||||
export function verifyAdminProof(macKeyOrSeed, proof, ctx) {
|
||||
if (!proof || !proof.nonce || !proof.mac) {
|
||||
return { ok: false, error: 'Missing admin proof', code: 'ADMIN_PROOF_FAILED' }
|
||||
}
|
||||
const nonce = String(proof.nonce).toLowerCase()
|
||||
const macHex = String(proof.mac).toLowerCase()
|
||||
if (!/^[0-9a-f]{16,64}$/.test(nonce) || !/^[0-9a-f]{64}$/.test(macHex)) {
|
||||
return { ok: false, error: 'Malformed admin proof', code: 'ADMIN_PROOF_FAILED' }
|
||||
}
|
||||
const peerId = String(ctx.peerId || '').toLowerCase()
|
||||
const serverPk = String(ctx.serverPublicKeyHex || '').toLowerCase()
|
||||
if (!/^[0-9a-f]{64}$/.test(peerId) || !/^[0-9a-f]{64}$/.test(serverPk)) {
|
||||
return { ok: false, error: 'Invalid proof context', code: 'ADMIN_PROOF_FAILED' }
|
||||
}
|
||||
|
||||
const macKey = resolveMacKey(macKeyOrSeed)
|
||||
const expected = hmacAdmin(macKey, nonce, peerId, serverPk)
|
||||
const got = Buffer.from(macHex, 'hex')
|
||||
if (!safeEqual(got, expected)) {
|
||||
return { ok: false, error: 'Admin proof verification failed', code: 'ADMIN_PROOF_FAILED' }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} pkg
|
||||
* @returns {string}
|
||||
*/
|
||||
export function encodeInvite(pkg) {
|
||||
const body = {
|
||||
v: 1,
|
||||
publicKeyHex: String(pkg.publicKeyHex || '').toLowerCase(),
|
||||
capability: String(pkg.capability || ''),
|
||||
role: pkg.role || null,
|
||||
jti: pkg.jti || null,
|
||||
alias: pkg.alias || null,
|
||||
expiresAt: pkg.expiresAt ?? null,
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(body.publicKeyHex)) {
|
||||
throw new Error('encodeInvite: invalid publicKeyHex')
|
||||
}
|
||||
if (!body.capability || !body.capability.includes('.')) {
|
||||
throw new Error('encodeInvite: invalid capability')
|
||||
}
|
||||
return `${INVITE_PREFIX}${b64url(Buffer.from(JSON.stringify(body), 'utf8'))}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} invite
|
||||
* @returns {{ ok: true, package: object } | { ok: false, error: string, code: string }}
|
||||
*/
|
||||
export function decodeInvite(invite) {
|
||||
const s = String(invite || '').trim()
|
||||
if (!s.startsWith(INVITE_PREFIX)) {
|
||||
return { ok: false, error: 'Not a pa1 invite', code: 'INVITE_INVALID' }
|
||||
}
|
||||
try {
|
||||
const json = b64urlDecode(s.slice(INVITE_PREFIX.length)).toString('utf8')
|
||||
const pkg = JSON.parse(json)
|
||||
if (!pkg?.publicKeyHex || !pkg?.capability) {
|
||||
return { ok: false, error: 'Invite missing fields', code: 'INVITE_INVALID' }
|
||||
}
|
||||
return { ok: true, package: pkg }
|
||||
} catch {
|
||||
return { ok: false, error: 'Malformed invite', code: 'INVITE_INVALID' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify free-form connection input (public key, invite, capability).
|
||||
* @param {string} input
|
||||
*/
|
||||
export function classifyConnectionInput(input) {
|
||||
const s = String(input || '').trim()
|
||||
if (!s) return { kind: 'empty' }
|
||||
if (s.startsWith(INVITE_PREFIX)) {
|
||||
const dec = decodeInvite(s)
|
||||
if (!dec.ok) return { kind: 'invalid', error: dec.error, code: dec.code }
|
||||
return {
|
||||
kind: 'invite',
|
||||
publicKeyHex: dec.package.publicKeyHex,
|
||||
capability: dec.package.capability,
|
||||
role: dec.package.role,
|
||||
alias: dec.package.alias,
|
||||
}
|
||||
}
|
||||
if (/^[0-9a-fA-F]{64}$/.test(s)) {
|
||||
return { kind: 'publicKey', publicKeyHex: s.toLowerCase() }
|
||||
}
|
||||
if (s.includes('.') && s.split('.').length === 2) {
|
||||
return { kind: 'capability', capability: s }
|
||||
}
|
||||
return { kind: 'unknown', error: 'Expected 64-hex public key or pd1. invite' }
|
||||
}
|
||||
|
||||
export function isInsecureOpenAdmin() {
|
||||
const v = String(process.env.PEARDATA_INSECURE_OPEN_ADMIN || '').toLowerCase()
|
||||
return v === '1' || v === 'true' || v === 'yes'
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Canonical data-model helpers + JSDoc typedefs for metrics, anomalies, health.
|
||||
*
|
||||
* Wire shapes are JSON over protomux-rpc compact-encoding and REST /api/v*.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* nodeId: string,
|
||||
* hostname: string,
|
||||
* publicKeyHex: string,
|
||||
* platform: string,
|
||||
* arch: string,
|
||||
* release: string,
|
||||
* cpus: number,
|
||||
* totalMemMiB: number,
|
||||
* agentVersion: string,
|
||||
* protocolVersion: number,
|
||||
* startedAt: number,
|
||||
* labels?: Record<string, string>,
|
||||
* }} NodeInfo
|
||||
*
|
||||
* @typedef {{
|
||||
* chart: string,
|
||||
* context: string,
|
||||
* ts: number,
|
||||
* values: Record<string, number|null>,
|
||||
* }} MetricSample
|
||||
*
|
||||
* @typedef {{
|
||||
* chart: string,
|
||||
* context: string,
|
||||
* labels: string[],
|
||||
* data: Array<[number, ...(number|null)[]]>,
|
||||
* view_update_every?: number,
|
||||
* }} QueryResult
|
||||
*
|
||||
* @typedef {{
|
||||
* id: string,
|
||||
* chart: string,
|
||||
* context: string,
|
||||
* dimension: string,
|
||||
* severity: 'warning'|'critical',
|
||||
* score: number,
|
||||
* value: number,
|
||||
* threshold: number,
|
||||
* comparator: string,
|
||||
* message: string,
|
||||
* ts: number,
|
||||
* cleared?: boolean,
|
||||
* }} AnomalyEvent
|
||||
*
|
||||
* @typedef {{
|
||||
* id: string,
|
||||
* name: string,
|
||||
* chart: string,
|
||||
* dimension: string,
|
||||
* status: 'CLEAR'|'WARNING'|'CRITICAL'|'UNDEFINED',
|
||||
* value: number|null,
|
||||
* units: string,
|
||||
* info: string,
|
||||
* lastStatusChange: number,
|
||||
* config: AlertConfig,
|
||||
* }} AlertState
|
||||
*
|
||||
* @typedef {{
|
||||
* id: string,
|
||||
* chart: string,
|
||||
* dimension: string,
|
||||
* warn?: number|null,
|
||||
* crit?: number|null,
|
||||
* comparator?: '>'|'<'|'>='|'<=',
|
||||
* lookbackSec?: number,
|
||||
* enabled?: boolean,
|
||||
* info?: string,
|
||||
* }} AlertConfig
|
||||
*
|
||||
* @typedef {{
|
||||
* status: 'ok'|'degraded'|'critical',
|
||||
* score: number,
|
||||
* checks: Array<{ id: string, ok: boolean, detail: string }>,
|
||||
* ts: number,
|
||||
* }} HealthSnapshot
|
||||
*
|
||||
* @typedef {{
|
||||
* id: string,
|
||||
* name: string,
|
||||
* status: 'queued'|'running'|'done'|'failed'|'cancelled',
|
||||
* startedAt: number|null,
|
||||
* finishedAt: number|null,
|
||||
* result?: any,
|
||||
* error?: string,
|
||||
* }} JobRecord
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Partial<MetricSample>} sample
|
||||
* @returns {MetricSample}
|
||||
*/
|
||||
export function normalizeSample(sample) {
|
||||
return {
|
||||
chart: String(sample.chart || ''),
|
||||
context: String(sample.context || sample.chart || ''),
|
||||
ts: Number(sample.ts) || Date.now(),
|
||||
values: sample.values && typeof sample.values === 'object' ? sample.values : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Partial<AnomalyEvent>} ev
|
||||
* @returns {AnomalyEvent}
|
||||
*/
|
||||
export function normalizeAnomaly(ev) {
|
||||
return {
|
||||
id: String(ev.id || `${ev.chart}:${ev.dimension}:${ev.ts}`),
|
||||
chart: String(ev.chart || ''),
|
||||
context: String(ev.context || ev.chart || ''),
|
||||
dimension: String(ev.dimension || ''),
|
||||
severity: ev.severity === 'critical' ? 'critical' : 'warning',
|
||||
score: Number(ev.score) || 0,
|
||||
value: Number(ev.value),
|
||||
threshold: Number(ev.threshold),
|
||||
comparator: String(ev.comparator || '>'),
|
||||
message: String(ev.message || ''),
|
||||
ts: Number(ev.ts) || Date.now(),
|
||||
cleared: Boolean(ev.cleared),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Value encodings for protomux-rpc.
|
||||
*
|
||||
* Default: compact-encoding JSON (stable, human-debuggable).
|
||||
* Future: swap valueEncoding per-method to hyperschema structs while
|
||||
* keeping method names stable (PROTOCOL_VERSION bump when required).
|
||||
*/
|
||||
import c from 'compact-encoding'
|
||||
import { SCHEMA_VERSION } from './schema.js'
|
||||
|
||||
export const json = c.json
|
||||
export const raw = c.raw
|
||||
export const none = c.none
|
||||
|
||||
export const encodings = {
|
||||
valueEncoding: json,
|
||||
requestEncoding: json,
|
||||
responseEncoding: json,
|
||||
}
|
||||
|
||||
export const ENCODING_PROFILE = {
|
||||
name: 'json',
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
future: 'hyperschema per-method valueEncoding',
|
||||
}
|
||||
|
||||
export { SCHEMA_VERSION }
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Metric contexts, charts, and dimension catalog (Netdata-inspired).
|
||||
*
|
||||
* Context IDs follow Netdata style: family.metric (e.g. system.cpu).
|
||||
* Chart IDs are unique per agent instance.
|
||||
*/
|
||||
|
||||
export const SAMPLE_INTERVAL_MS = 1000
|
||||
|
||||
/**
|
||||
* @typedef {{ id: string, name: string, algorithm: 'absolute'|'incremental', multiplier?: number, divisor?: number }} DimensionDef
|
||||
* @typedef {{
|
||||
* id: string,
|
||||
* name: string,
|
||||
* context: string,
|
||||
* title: string,
|
||||
* units: string,
|
||||
* family: string,
|
||||
* chartType: 'line'|'area'|'stacked',
|
||||
* priority: number,
|
||||
* dimensions: DimensionDef[],
|
||||
* }} ChartDef
|
||||
*/
|
||||
|
||||
/** @type {ChartDef[]} */
|
||||
export const CHART_DEFS = [
|
||||
{
|
||||
id: 'system.cpu',
|
||||
name: 'system.cpu',
|
||||
context: 'system.cpu',
|
||||
title: 'Total CPU utilization',
|
||||
units: 'percentage',
|
||||
family: 'cpu',
|
||||
chartType: 'stacked',
|
||||
priority: 100,
|
||||
dimensions: [
|
||||
{ id: 'user', name: 'user', algorithm: 'absolute' },
|
||||
{ id: 'system', name: 'system', algorithm: 'absolute' },
|
||||
{ id: 'nice', name: 'nice', algorithm: 'absolute' },
|
||||
{ id: 'iowait', name: 'iowait', algorithm: 'absolute' },
|
||||
{ id: 'irq', name: 'irq', algorithm: 'absolute' },
|
||||
{ id: 'softirq', name: 'softirq', algorithm: 'absolute' },
|
||||
{ id: 'idle', name: 'idle', algorithm: 'absolute' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system.ram',
|
||||
name: 'system.ram',
|
||||
context: 'system.ram',
|
||||
title: 'System RAM',
|
||||
units: 'MiB',
|
||||
family: 'memory',
|
||||
chartType: 'stacked',
|
||||
priority: 200,
|
||||
dimensions: [
|
||||
{ id: 'used', name: 'used', algorithm: 'absolute' },
|
||||
{ id: 'cached', name: 'cached', algorithm: 'absolute' },
|
||||
{ id: 'buffers', name: 'buffers', algorithm: 'absolute' },
|
||||
{ id: 'free', name: 'free', algorithm: 'absolute' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system.load',
|
||||
name: 'system.load',
|
||||
context: 'system.load',
|
||||
title: 'System Load Average',
|
||||
units: 'load',
|
||||
family: 'load',
|
||||
chartType: 'line',
|
||||
priority: 300,
|
||||
dimensions: [
|
||||
{ id: 'load1', name: 'load1', algorithm: 'absolute' },
|
||||
{ id: 'load5', name: 'load5', algorithm: 'absolute' },
|
||||
{ id: 'load15', name: 'load15', algorithm: 'absolute' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system.io',
|
||||
name: 'system.io',
|
||||
context: 'system.io',
|
||||
title: 'Disk I/O',
|
||||
units: 'KiB/s',
|
||||
family: 'disk',
|
||||
chartType: 'area',
|
||||
priority: 400,
|
||||
dimensions: [
|
||||
{ id: 'reads', name: 'reads', algorithm: 'incremental' },
|
||||
{ id: 'writes', name: 'writes', algorithm: 'incremental' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system.net',
|
||||
name: 'system.net',
|
||||
context: 'system.net',
|
||||
title: 'Bandwidth',
|
||||
units: 'kilobits/s',
|
||||
family: 'network',
|
||||
chartType: 'area',
|
||||
priority: 500,
|
||||
dimensions: [
|
||||
{ id: 'received', name: 'received', algorithm: 'incremental' },
|
||||
{ id: 'sent', name: 'sent', algorithm: 'incremental' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system.processes',
|
||||
name: 'system.processes',
|
||||
context: 'system.processes',
|
||||
title: 'System Processes',
|
||||
units: 'processes',
|
||||
family: 'processes',
|
||||
chartType: 'line',
|
||||
priority: 600,
|
||||
dimensions: [
|
||||
{ id: 'running', name: 'running', algorithm: 'absolute' },
|
||||
{ id: 'blocked', name: 'blocked', algorithm: 'absolute' },
|
||||
{ id: 'total', name: 'total', algorithm: 'absolute' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system.uptime',
|
||||
name: 'system.uptime',
|
||||
context: 'system.uptime',
|
||||
title: 'System Uptime',
|
||||
units: 'seconds',
|
||||
family: 'uptime',
|
||||
chartType: 'line',
|
||||
priority: 700,
|
||||
dimensions: [{ id: 'uptime', name: 'uptime', algorithm: 'absolute' }],
|
||||
},
|
||||
{
|
||||
id: 'mem.available',
|
||||
name: 'mem.available',
|
||||
context: 'mem.available',
|
||||
title: 'Available RAM',
|
||||
units: 'MiB',
|
||||
family: 'memory',
|
||||
chartType: 'area',
|
||||
priority: 210,
|
||||
dimensions: [{ id: 'avail', name: 'avail', algorithm: 'absolute' }],
|
||||
},
|
||||
]
|
||||
|
||||
/** @type {Map<string, ChartDef>} */
|
||||
export const CHART_BY_ID = new Map(CHART_DEFS.map((c) => [c.id, c]))
|
||||
|
||||
/** Unique context ids */
|
||||
export const CONTEXT_IDS = [...new Set(CHART_DEFS.map((c) => c.context))]
|
||||
|
||||
/**
|
||||
* @param {string} context
|
||||
*/
|
||||
export function chartsForContext(context) {
|
||||
return CHART_DEFS.filter((c) => c.context === context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Netdata-ish chart summary object.
|
||||
* @param {ChartDef} def
|
||||
* @param {{ firstEntry?: number, lastEntry?: number, updateEvery?: number }} [meta]
|
||||
*/
|
||||
export function chartSummary(def, meta = {}) {
|
||||
const dimensions = {}
|
||||
for (const d of def.dimensions) {
|
||||
dimensions[d.id] = {
|
||||
name: d.name,
|
||||
algorithm: d.algorithm,
|
||||
multiplier: d.multiplier ?? 1,
|
||||
divisor: d.divisor ?? 1,
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: def.id,
|
||||
name: def.name,
|
||||
type: 'system',
|
||||
family: def.family,
|
||||
context: def.context,
|
||||
title: def.title,
|
||||
units: def.units,
|
||||
chart_type: def.chartType,
|
||||
priority: def.priority,
|
||||
update_every: meta.updateEvery ?? SAMPLE_INTERVAL_MS / 1000,
|
||||
first_entry: meta.firstEntry ?? 0,
|
||||
last_entry: meta.lastEntry ?? 0,
|
||||
dimensions,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* PearData P2P protocol — control plane + metric streaming.
|
||||
*
|
||||
* PROTOCOL_VERSION is negotiated via `handshake`. Bump on breaking RPC shapes.
|
||||
* Additive methods may land without a bump when clients tolerate unknown methods.
|
||||
*
|
||||
* Planes:
|
||||
* - Control/metadata: handshake, info, ACL, invites, jobs, alerts config
|
||||
* - Metrics query: contexts, charts, historical data (hot path)
|
||||
* - Push streams: live samples, anomalies, health events
|
||||
*/
|
||||
|
||||
export const PROTOCOL = 'peardata/rpc'
|
||||
export const PROTOCOL_VERSION = 1
|
||||
export const APP_NAME = 'peardata'
|
||||
export const APP_VERSION = '0.1.0'
|
||||
|
||||
/** Roles for capability ACL. Secure default peer role is viewer. */
|
||||
export const Roles = Object.freeze({
|
||||
viewer: 'viewer',
|
||||
operator: 'operator',
|
||||
admin: 'admin',
|
||||
})
|
||||
|
||||
const ROLE_RANK = {
|
||||
[Roles.viewer]: 1,
|
||||
[Roles.operator]: 2,
|
||||
[Roles.admin]: 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} role
|
||||
* @param {string} required
|
||||
*/
|
||||
export function roleAllows(role, required) {
|
||||
const have = ROLE_RANK[role] || 0
|
||||
const need = ROLE_RANK[required] || ROLE_RANK[Roles.admin]
|
||||
return have >= need
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum role required per method (default admin for unknown).
|
||||
*/
|
||||
export const MethodRoles = Object.freeze({
|
||||
// session
|
||||
handshake: Roles.viewer,
|
||||
ping: Roles.viewer,
|
||||
getServerInfo: Roles.viewer,
|
||||
getAuthStatus: Roles.viewer,
|
||||
setDisplayName: Roles.viewer,
|
||||
|
||||
// agent / node metadata
|
||||
getNodeInfo: Roles.viewer,
|
||||
getHealth: Roles.viewer,
|
||||
|
||||
// metrics discovery + query
|
||||
listContexts: Roles.viewer,
|
||||
getContext: Roles.viewer,
|
||||
listCharts: Roles.viewer,
|
||||
getChart: Roles.viewer,
|
||||
queryData: Roles.viewer,
|
||||
getAllMetrics: Roles.viewer,
|
||||
|
||||
// live subscription control
|
||||
subscribeMetrics: Roles.viewer,
|
||||
unsubscribeMetrics: Roles.viewer,
|
||||
subscribeAnomalies: Roles.viewer,
|
||||
unsubscribeAnomalies: Roles.viewer,
|
||||
|
||||
// anomalies + alerts (read)
|
||||
listAnomalies: Roles.viewer,
|
||||
listAlerts: Roles.viewer,
|
||||
getAlert: Roles.viewer,
|
||||
|
||||
// alerts / thresholds (write)
|
||||
setAlertConfig: Roles.operator,
|
||||
ackAlert: Roles.operator,
|
||||
silenceAlert: Roles.operator,
|
||||
|
||||
// jobs (on-demand collection / maintenance)
|
||||
listJobs: Roles.viewer,
|
||||
runJob: Roles.operator,
|
||||
cancelJob: Roles.operator,
|
||||
|
||||
// admin / fleet
|
||||
mintInvite: Roles.admin,
|
||||
listPeers: Roles.admin,
|
||||
revokePeer: Roles.admin,
|
||||
exportSnapshot: Roles.admin,
|
||||
})
|
||||
|
||||
/**
|
||||
* String method names for typed client calls.
|
||||
*/
|
||||
export const Methods = Object.freeze(
|
||||
Object.fromEntries(Object.keys(MethodRoles).map((k) => [k, k]))
|
||||
)
|
||||
|
||||
/**
|
||||
* Server → client push event names (protomux-rpc events).
|
||||
*/
|
||||
export const Pushes = Object.freeze({
|
||||
metrics: 'push:metrics',
|
||||
anomaly: 'push:anomaly',
|
||||
alert: 'push:alert',
|
||||
health: 'push:health',
|
||||
job: 'push:job',
|
||||
system: 'push:system',
|
||||
})
|
||||
|
||||
/**
|
||||
* Map push channel → UI event type.
|
||||
*/
|
||||
export const PushToType = Object.freeze({
|
||||
[Pushes.metrics]: 'metrics',
|
||||
[Pushes.anomaly]: 'anomaly',
|
||||
[Pushes.alert]: 'alert',
|
||||
[Pushes.health]: 'health',
|
||||
[Pushes.job]: 'job',
|
||||
[Pushes.system]: 'system',
|
||||
})
|
||||
|
||||
/** Methods treated as hot-path (skip heavy audit / schema). */
|
||||
export const HotMethods = Object.freeze(
|
||||
new Set([
|
||||
Methods.ping,
|
||||
Methods.queryData,
|
||||
Methods.subscribeMetrics,
|
||||
Methods.unsubscribeMetrics,
|
||||
Methods.getAllMetrics,
|
||||
])
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Lightweight arg validation for PearData RPCs.
|
||||
*/
|
||||
|
||||
export const SCHEMA_VERSION = 1
|
||||
|
||||
/**
|
||||
* @typedef {{ ok: true, args: object } | { ok: false, error: string }} ValidateResult
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {string} method
|
||||
* @param {object} args
|
||||
* @returns {ValidateResult}
|
||||
*/
|
||||
export function validateMethodArgs(method, args = {}) {
|
||||
if (args == null || typeof args !== 'object' || Array.isArray(args)) {
|
||||
return { ok: false, error: 'Arguments must be a plain object' }
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case 'handshake':
|
||||
case 'ping':
|
||||
case 'getServerInfo':
|
||||
case 'getAuthStatus':
|
||||
case 'getNodeInfo':
|
||||
case 'getHealth':
|
||||
case 'listContexts':
|
||||
case 'listCharts':
|
||||
case 'listAnomalies':
|
||||
case 'listAlerts':
|
||||
case 'listJobs':
|
||||
case 'listPeers':
|
||||
return { ok: true, args }
|
||||
|
||||
case 'setDisplayName': {
|
||||
const name = String(args.name ?? '').trim()
|
||||
if (!name) return { ok: false, error: 'name is required' }
|
||||
if (name.length > 40) return { ok: false, error: 'name must be ≤ 40 characters' }
|
||||
return { ok: true, args: { ...args, name } }
|
||||
}
|
||||
|
||||
case 'getContext':
|
||||
case 'getChart':
|
||||
case 'getAlert': {
|
||||
const id = String(args.id || args.chart || args.context || '').trim()
|
||||
if (!id) return { ok: false, error: 'id is required' }
|
||||
return { ok: true, args: { ...args, id } }
|
||||
}
|
||||
|
||||
case 'queryData': {
|
||||
const chart = String(args.chart || args.context || '').trim()
|
||||
if (!chart) return { ok: false, error: 'chart or context is required' }
|
||||
const points = args.points == null ? 60 : Number(args.points)
|
||||
if (!Number.isFinite(points) || points < 1 || points > 10_000) {
|
||||
return { ok: false, error: 'points must be 1..10000' }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
args: {
|
||||
...args,
|
||||
chart,
|
||||
points,
|
||||
after: args.after != null ? Number(args.after) : -points,
|
||||
before: args.before != null ? Number(args.before) : 0,
|
||||
group: String(args.group || 'average'),
|
||||
format: String(args.format || 'json'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
case 'subscribeMetrics': {
|
||||
const charts = Array.isArray(args.charts)
|
||||
? args.charts.map(String)
|
||||
: args.chart
|
||||
? [String(args.chart)]
|
||||
: ['*']
|
||||
const intervalMs = Math.max(500, Number(args.intervalMs) || 1000)
|
||||
return { ok: true, args: { ...args, charts, intervalMs } }
|
||||
}
|
||||
|
||||
case 'unsubscribeMetrics':
|
||||
case 'unsubscribeAnomalies':
|
||||
return { ok: true, args }
|
||||
|
||||
case 'subscribeAnomalies':
|
||||
return { ok: true, args }
|
||||
|
||||
case 'setAlertConfig': {
|
||||
const id = String(args.id || '').trim()
|
||||
if (!id) return { ok: false, error: 'id is required' }
|
||||
return { ok: true, args: { ...args, id } }
|
||||
}
|
||||
|
||||
case 'ackAlert':
|
||||
case 'silenceAlert': {
|
||||
const id = String(args.id || '').trim()
|
||||
if (!id) return { ok: false, error: 'id is required' }
|
||||
return { ok: true, args: { ...args, id } }
|
||||
}
|
||||
|
||||
case 'runJob': {
|
||||
const name = String(args.name || args.job || '').trim()
|
||||
if (!name) return { ok: false, error: 'name is required' }
|
||||
return { ok: true, args: { ...args, name } }
|
||||
}
|
||||
|
||||
case 'cancelJob': {
|
||||
const id = String(args.id || '').trim()
|
||||
if (!id) return { ok: false, error: 'id is required' }
|
||||
return { ok: true, args: { ...args, id } }
|
||||
}
|
||||
|
||||
case 'mintInvite': {
|
||||
const role = String(args.role || 'operator').toLowerCase()
|
||||
if (!['viewer', 'operator', 'admin'].includes(role)) {
|
||||
return { ok: false, error: 'role must be viewer|operator|admin' }
|
||||
}
|
||||
return { ok: true, args: { ...args, role } }
|
||||
}
|
||||
|
||||
case 'revokePeer': {
|
||||
const peerId = String(args.peerId || '').toLowerCase()
|
||||
if (!/^[0-9a-f]{64}$/.test(peerId)) {
|
||||
return { ok: false, error: 'peerId must be 64 hex characters' }
|
||||
}
|
||||
return { ok: true, args: { ...args, peerId } }
|
||||
}
|
||||
|
||||
case 'getAllMetrics': {
|
||||
const format = String(args.format || 'json').toLowerCase()
|
||||
if (!['json', 'prometheus', 'shell'].includes(format)) {
|
||||
return { ok: false, error: 'format must be json|prometheus|shell' }
|
||||
}
|
||||
return { ok: true, args: { ...args, format } }
|
||||
}
|
||||
|
||||
case 'exportSnapshot':
|
||||
return { ok: true, args }
|
||||
|
||||
default:
|
||||
return { ok: true, args }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from 'brittle'
|
||||
import { Roles, roleAllows, MethodRoles } from '../shared/protocol.js'
|
||||
import { assertAllowed, maxRole } from '../server/core/acl.js'
|
||||
|
||||
test('assertAllowed allows operator postMessage', (t) => {
|
||||
t.exception(() => assertAllowed(Roles.viewer, 'postMessage'))
|
||||
assertAllowed(Roles.operator, 'postMessage')
|
||||
assertAllowed(Roles.admin, 'clearMessages')
|
||||
t.pass()
|
||||
})
|
||||
|
||||
test('maxRole elevates', (t) => {
|
||||
t.is(maxRole(Roles.viewer, Roles.operator), Roles.operator)
|
||||
t.is(maxRole(Roles.admin, Roles.operator), Roles.admin)
|
||||
})
|
||||
|
||||
test('viewer can read', (t) => {
|
||||
t.ok(roleAllows(Roles.viewer, MethodRoles.listMessages))
|
||||
t.ok(roleAllows(Roles.viewer, MethodRoles.handshake))
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import test from 'brittle'
|
||||
import {
|
||||
deriveMacKey,
|
||||
signCapability,
|
||||
verifyCapability,
|
||||
createAdminProof,
|
||||
verifyAdminProof,
|
||||
encodeInvite,
|
||||
decodeInvite,
|
||||
classifyConnectionInput,
|
||||
safeEqual,
|
||||
} from '../shared/crypto-auth.js'
|
||||
import { Roles } from '../shared/protocol.js'
|
||||
|
||||
const SEED = 'ab'.repeat(32)
|
||||
const SEED2 = 'cd'.repeat(32)
|
||||
const PEER = '11'.repeat(32)
|
||||
const SERVER_PK = '22'.repeat(32)
|
||||
|
||||
test('deriveMacKey is deterministic and differs by seed', (t) => {
|
||||
const a = deriveMacKey(SEED)
|
||||
const b = deriveMacKey(SEED)
|
||||
const c = deriveMacKey(SEED2)
|
||||
t.is(a.length, 32)
|
||||
t.ok(safeEqual(a, b))
|
||||
t.absent(safeEqual(a, c))
|
||||
})
|
||||
|
||||
test('sign and verify capability', (t) => {
|
||||
const { token, payload } = signCapability(SEED, {
|
||||
role: Roles.operator,
|
||||
ttlMs: 3600_000,
|
||||
})
|
||||
t.ok(token.includes('.'))
|
||||
t.is(payload.role, Roles.operator)
|
||||
const res = verifyCapability(SEED, token, { peerId: PEER })
|
||||
t.ok(res.ok)
|
||||
t.is(res.payload.role, Roles.operator)
|
||||
})
|
||||
|
||||
test('tampered capability fails', (t) => {
|
||||
const { token } = signCapability(SEED, { role: Roles.admin, ttlMs: 3600_000 })
|
||||
const [body, mac] = token.split('.')
|
||||
const flipped = body.slice(0, -1) + (body.endsWith('A') ? 'B' : 'A') + '.' + mac
|
||||
const res = verifyCapability(SEED, flipped)
|
||||
t.absent(res.ok)
|
||||
t.is(res.code, 'CAPABILITY_INVALID')
|
||||
})
|
||||
|
||||
test('expired capability fails', (t) => {
|
||||
const { token } = signCapability(SEED, { role: Roles.viewer, ttlMs: 60_000 })
|
||||
const res = verifyCapability(SEED, token, { now: Date.now() + 120_000 })
|
||||
t.absent(res.ok)
|
||||
t.is(res.code, 'CAPABILITY_EXPIRED')
|
||||
})
|
||||
|
||||
test('persistent capability never expires', (t) => {
|
||||
const { token, payload } = signCapability(SEED, {
|
||||
role: Roles.operator,
|
||||
forever: true,
|
||||
})
|
||||
t.is(payload.exp, null)
|
||||
const res = verifyCapability(SEED, token, {
|
||||
now: Date.now() + 100 * 365 * 24 * 3600 * 1000,
|
||||
})
|
||||
t.ok(res.ok)
|
||||
})
|
||||
|
||||
test('admin proof round-trip', (t) => {
|
||||
const proof = createAdminProof(SEED, {
|
||||
peerId: PEER,
|
||||
serverPublicKeyHex: SERVER_PK,
|
||||
})
|
||||
const ok = verifyAdminProof(SEED, proof, {
|
||||
peerId: PEER,
|
||||
serverPublicKeyHex: SERVER_PK,
|
||||
})
|
||||
t.ok(ok.ok)
|
||||
const bad = verifyAdminProof(SEED2, proof, {
|
||||
peerId: PEER,
|
||||
serverPublicKeyHex: SERVER_PK,
|
||||
})
|
||||
t.absent(bad.ok)
|
||||
})
|
||||
|
||||
test('invite encode/decode + classify', (t) => {
|
||||
const { token } = signCapability(SEED, { role: Roles.operator, forever: true })
|
||||
const invite = encodeInvite({
|
||||
publicKeyHex: SERVER_PK,
|
||||
capability: token,
|
||||
role: Roles.operator,
|
||||
})
|
||||
t.ok(invite.startsWith('pd1.'))
|
||||
const dec = decodeInvite(invite)
|
||||
t.ok(dec.ok)
|
||||
t.is(dec.package.publicKeyHex, SERVER_PK)
|
||||
const cls = classifyConnectionInput(invite)
|
||||
t.is(cls.kind, 'invite')
|
||||
t.is(cls.publicKeyHex, SERVER_PK)
|
||||
t.is(classifyConnectionInput(SERVER_PK).kind, 'publicKey')
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* End-to-end: ephemeral HyperDHT server + client RPC + push.
|
||||
* Runs against real DHT (local, no bootstrap dependency for same-process? )
|
||||
*
|
||||
* hyperdht connect needs the DHT network; same-machine servers work via
|
||||
* the default bootstrap / local discovery. May be slow on restricted networks.
|
||||
*
|
||||
* Skip with: SKIP_INTEGRATION=1 npm test
|
||||
*/
|
||||
import test from 'brittle'
|
||||
import DHT from 'hyperdht'
|
||||
import b4a from 'b4a'
|
||||
import crypto from 'hypercore-crypto'
|
||||
import { PeerSession } from '../server/rpc/session.js'
|
||||
import { registerAllHandlers, cleanupSession } from '../server/rpc/register.js'
|
||||
import { peers } from '../server/core/peer-registry.js'
|
||||
import { initAuthKeys } from '../server/core/auth-keys.js'
|
||||
import { PearDataConnection } from '../client/connection.js'
|
||||
import { Methods, Pushes } from '../shared/protocol.js'
|
||||
import { signCapability } from '../shared/crypto-auth.js'
|
||||
|
||||
const skip = process.env.SKIP_INTEGRATION === '1'
|
||||
|
||||
test('integration: dial, handshake, post, push', { skip, timeout: 60_000 }, async (t) => {
|
||||
const seed = crypto.randomBytes(32)
|
||||
const keyPair = DHT.keyPair(seed)
|
||||
const publicKeyHex = b4a.toString(keyPair.publicKey, 'hex')
|
||||
const seedHex = b4a.toString(seed, 'hex')
|
||||
|
||||
initAuthKeys({ seedHex, publicKeyHex })
|
||||
|
||||
// Open admin for this test process
|
||||
process.env.PEARDATA_INSECURE_OPEN_ADMIN = '1'
|
||||
|
||||
const dht = new DHT()
|
||||
const server = dht.createServer()
|
||||
|
||||
server.on('connection', (socket) => {
|
||||
const session = new PeerSession(socket, {
|
||||
serverPublicKey: keyPair.publicKey,
|
||||
onClose: (s) => {
|
||||
cleanupSession(s)
|
||||
peers.remove(s.id)
|
||||
},
|
||||
})
|
||||
registerAllHandlers(session)
|
||||
peers.add(session)
|
||||
})
|
||||
|
||||
await server.listen(keyPair)
|
||||
t.teardown(async () => {
|
||||
for (const s of peers.list()) s.destroy()
|
||||
await server.close().catch(() => {})
|
||||
await dht.destroy().catch(() => {})
|
||||
delete process.env.PEARDATA_INSECURE_OPEN_ADMIN
|
||||
})
|
||||
|
||||
const conn = new PearDataConnection(publicKeyHex, {
|
||||
adminSeed: seedHex,
|
||||
timeoutMs: 45_000,
|
||||
})
|
||||
|
||||
/** @type {object[]} */
|
||||
const pushes = []
|
||||
conn.on(Pushes.message, (m) => pushes.push(m))
|
||||
|
||||
await conn.connect()
|
||||
t.is(conn.role, 'admin')
|
||||
t.ok(conn.connected)
|
||||
|
||||
const pong = await conn.request(Methods.ping, {})
|
||||
t.ok(pong.ok)
|
||||
|
||||
await conn.request(Methods.setDisplayName, { name: 'tester' })
|
||||
const post = await conn.request(Methods.postMessage, { text: 'hello p2p' })
|
||||
t.ok(post.success)
|
||||
t.is(post.message.text, 'hello p2p')
|
||||
|
||||
// Allow push delivery
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
const list = await conn.request(Methods.listMessages, {})
|
||||
t.ok(list.messages.some((m) => m.text === 'hello p2p'))
|
||||
|
||||
const invite = await conn.request(Methods.mintInvite, { role: 'operator', ttlMs: 3600_000 })
|
||||
t.ok(invite.invite.startsWith('pd1.'))
|
||||
|
||||
// Capability can be verified offline
|
||||
const cap = signCapability(seedHex, { role: 'viewer', forever: true })
|
||||
t.ok(cap.token.includes('.'))
|
||||
|
||||
await conn.destroy()
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'brittle'
|
||||
import {
|
||||
Roles,
|
||||
roleAllows,
|
||||
MethodRoles,
|
||||
PROTOCOL,
|
||||
PROTOCOL_VERSION,
|
||||
} from '../shared/protocol.js'
|
||||
import { validateMethodArgs, SCHEMA_VERSION } from '../shared/schema.js'
|
||||
|
||||
test('protocol constants', (t) => {
|
||||
t.ok(PROTOCOL.includes('/rpc'))
|
||||
t.ok(PROTOCOL_VERSION >= 1)
|
||||
t.ok(SCHEMA_VERSION >= 1)
|
||||
})
|
||||
|
||||
test('roleAllows hierarchy', (t) => {
|
||||
t.ok(roleAllows(Roles.admin, Roles.viewer))
|
||||
t.ok(roleAllows(Roles.operator, Roles.viewer))
|
||||
t.ok(roleAllows(Roles.operator, Roles.operator))
|
||||
t.absent(roleAllows(Roles.viewer, Roles.operator))
|
||||
t.absent(roleAllows(Roles.viewer, Roles.admin))
|
||||
})
|
||||
|
||||
test('method roles map covers core methods', (t) => {
|
||||
for (const m of ['handshake', 'ping', 'listMessages', 'postMessage', 'mintInvite']) {
|
||||
t.ok(MethodRoles[m], m)
|
||||
}
|
||||
})
|
||||
|
||||
test('validateMethodArgs postMessage', (t) => {
|
||||
t.absent(validateMethodArgs('postMessage', {}).ok)
|
||||
t.ok(validateMethodArgs('postMessage', { text: 'hi' }).ok)
|
||||
t.absent(validateMethodArgs('postMessage', { text: 'x'.repeat(2001) }).ok)
|
||||
})
|
||||
|
||||
test('validateMethodArgs setDisplayName', (t) => {
|
||||
t.ok(validateMethodArgs('setDisplayName', { name: 'Ada' }).ok)
|
||||
t.absent(validateMethodArgs('setDisplayName', { name: '' }).ok)
|
||||
})
|
||||
+467
@@ -0,0 +1,467 @@
|
||||
:root {
|
||||
--bg: #0b1020;
|
||||
--panel: #121a2f;
|
||||
--panel-2: #18223c;
|
||||
--border: #243154;
|
||||
--text: #e8eefc;
|
||||
--muted: #8b9bb8;
|
||||
--accent: #5b8cff;
|
||||
--accent-2: #3dd6c6;
|
||||
--danger: #ff6b7a;
|
||||
--ok: #3ecf8e;
|
||||
--radius: 12px;
|
||||
--font: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
/* Keep in sync with pear-ctrl / traffic-light alignment */
|
||||
--titlebar-h: 42px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(1200px 600px at 10% -10%, #1a2748 0%, var(--bg) 55%);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ─── Pear titlebar (drag + pear-ctrl window chrome) ─── */
|
||||
#titlebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: var(--titlebar-h);
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px 0 8px;
|
||||
gap: 0;
|
||||
background: rgba(11, 16, 32, 0.88);
|
||||
border-bottom: 1px solid var(--border);
|
||||
backdrop-filter: blur(12px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(1.3);
|
||||
/* Entire bar is a window drag surface */
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Interactive / chrome children must not start a drag */
|
||||
#titlebar > *,
|
||||
#titlebar .titlebar-left,
|
||||
#titlebar .titlebar-right,
|
||||
#titlebar pear-ctrl,
|
||||
#titlebar .app-brand,
|
||||
#titlebar .chip,
|
||||
#titlebar button,
|
||||
#titlebar a,
|
||||
#titlebar input {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.titlebar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 70%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.titlebar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* pear-ctrl: runtime custom element (darwin traffic lights / win+linux buttons) */
|
||||
#titlebar pear-ctrl {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
line-height: 0;
|
||||
min-width: 54px;
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
overflow: visible;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
#titlebar pear-ctrl[data-platform='darwin'] {
|
||||
min-width: 78px;
|
||||
margin: 0 8px 0 8px;
|
||||
}
|
||||
|
||||
#titlebar pear-ctrl[data-platform='linux'],
|
||||
#titlebar pear-ctrl[data-platform='win32'] {
|
||||
min-width: 110px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Standalone selectors (matches pear-bridge / pearcord conventions) */
|
||||
pear-ctrl[data-platform='darwin'] {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
/* Brand is visual only; keep drag on the bar around it */
|
||||
pointer-events: none;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.app-brand .logo {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
color: #061018;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-brand-text {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.app-brand-text strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-brand-text .muted {
|
||||
font-size: 11px;
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Main app fills remaining viewport under fixed titlebar */
|
||||
#app {
|
||||
margin-top: var(--titlebar-h);
|
||||
height: calc(100vh - var(--titlebar-h));
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
/* Content must never steal window drag from the titlebar */
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 11px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
-webkit-app-region: no-drag;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chip.offline {
|
||||
color: var(--danger);
|
||||
border-color: rgba(255, 107, 122, 0.35);
|
||||
}
|
||||
|
||||
.chip.online {
|
||||
color: var(--ok);
|
||||
border-color: rgba(62, 207, 142, 0.35);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr 280px;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: #c5d4f5;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0d1426;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(91, 140, 255, 0.15);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--border);
|
||||
background: #1a2540;
|
||||
color: var(--text);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: linear-gradient(135deg, #4d7fff, #3aa9ff);
|
||||
border-color: transparent;
|
||||
color: #061018;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: var(--danger);
|
||||
border-color: rgba(255, 107, 122, 0.35);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.room-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.room-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: #0a1224;
|
||||
padding: 12px;
|
||||
margin: 8px 0 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 160px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: #121c34;
|
||||
border: 1px solid rgba(36, 49, 84, 0.8);
|
||||
}
|
||||
|
||||
.msg .who {
|
||||
font-size: 11px;
|
||||
color: var(--accent-2);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.msg .body {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.msg .when {
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.composer input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.invite-out {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed var(--border);
|
||||
background: #0a1224;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.invite-out.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.presence {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.presence li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid rgba(36, 49, 84, 0.7);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.server-info,
|
||||
.log {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
background: #0a1224;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.log {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: #b7c9f5;
|
||||
}
|
||||
Reference in New Issue
Block a user