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