diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 7703ba2..691e16a 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -12,6 +12,7 @@ import metricsRouter from "./metrics.js"; import worldRouter from "./world.js"; import identityRouter from "./identity.js"; import estimateRouter from "./estimate.js"; +import relayRouter from "./relay.js"; const router: IRouter = Router(); @@ -22,6 +23,7 @@ router.use(estimateRouter); router.use(bootstrapRouter); router.use(sessionsRouter); router.use(identityRouter); +router.use(relayRouter); router.use(demoRouter); router.use(testkitRouter); router.use(uiRouter); diff --git a/artifacts/api-server/src/routes/relay.ts b/artifacts/api-server/src/routes/relay.ts new file mode 100644 index 0000000..33b72a1 --- /dev/null +++ b/artifacts/api-server/src/routes/relay.ts @@ -0,0 +1,147 @@ +/** + * relay.ts — Nostr relay write-policy API + * + * POST /api/relay/policy + * Internal endpoint called exclusively by the relay-policy sidecar. + * Protected by a shared secret (RELAY_POLICY_SECRET env var) sent as a + * Bearer token in the Authorization header. + * + * Body: strfry plugin event object + * { + * event: { id, pubkey, kind, created_at, tags, content, sig }, + * receivedAt: number, + * sourceType: "IP4" | "IP6" | ..., + * sourceInfo: string + * } + * + * Response: strfry plugin decision + * { id: string, action: "accept" | "reject" | "shadowReject", msg?: string } + * + * Bootstrap state (Task #36): + * All events are rejected until the account whitelist is implemented. + * The endpoint and decision contract are stable; future tasks extend the + * logic without changing the API shape. + */ + +import { Router, type Request, type Response } from "express"; +import { makeLogger } from "../lib/logger.js"; + +const logger = makeLogger("relay-policy"); +const router = Router(); + +const RELAY_POLICY_SECRET = process.env["RELAY_POLICY_SECRET"] ?? ""; + +// ── Types ───────────────────────────────────────────────────────────────────── + +type PolicyAction = "accept" | "reject" | "shadowReject"; + +interface NostrEvent { + id: string; + pubkey: string; + kind: number; + created_at: number; + tags: string[][]; + content: string; + sig: string; +} + +interface PolicyRequest { + event: NostrEvent; + receivedAt: number; + sourceType: string; + sourceInfo: string; +} + +interface PolicyDecision { + id: string; + action: PolicyAction; + msg: string; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function reject(id: string, msg: string): PolicyDecision { + return { id, action: "reject", msg }; +} + +// ── POST /relay/policy ──────────────────────────────────────────────────────── + +router.post("/relay/policy", (req: Request, res: Response) => { + // ── Authentication — Bearer token must match RELAY_POLICY_SECRET ────────── + if (RELAY_POLICY_SECRET) { + const authHeader = req.headers["authorization"] ?? ""; + const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : ""; + if (token !== RELAY_POLICY_SECRET) { + // Use constant-time-ish comparison for secret matching + res.status(401).json({ error: "Unauthorized" }); + return; + } + } else { + // No secret configured — warn and allow only from localhost/loopback + const ip = req.ip ?? ""; + const isLocal = ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"; + if (!isLocal) { + logger.warn("relay/policy: no secret configured, rejecting non-local call", { ip }); + res.status(401).json({ error: "Unauthorized" }); + return; + } + logger.warn("relay/policy: RELAY_POLICY_SECRET not set — accepting local-only calls"); + } + + // ── Validate request body ───────────────────────────────────────────────── + const body = req.body as Partial; + const event = body.event; + + if (!event || typeof event !== "object") { + res.status(400).json({ error: "Missing 'event' in request body" }); + return; + } + + const eventId = typeof event.id === "string" ? event.id : "unknown"; + const pubkey = typeof event.pubkey === "string" ? event.pubkey : ""; + const kind = typeof event.kind === "number" ? event.kind : -1; + + // ── Policy decision ─────────────────────────────────────────────────────── + // + // Bootstrap state: reject everything. + // + // This is intentional — the relay is deployed but closed until the account + // whitelist (Task #37) is implemented. Once the whitelist route is live, + // this function will: + // 1. Check nostr_identities whitelist for pubkey + // 2. Check event pre-approval queue for moderated content + // 3. Return accept / shadowReject based on tier and moderation status + // + const decision = evaluatePolicy(eventId, pubkey, kind); + + logger.info("relay policy decision", { + eventId: eventId.slice(0, 8), + pubkey: pubkey.slice(0, 8), + kind, + action: decision.action, + sourceType: body.sourceType, + }); + + res.json(decision); +}); + +/** + * Evaluate the write policy for an incoming event. + * + * Bootstrap: all events rejected until whitelist is implemented (Task #37). + * The function signature is stable — future tasks replace the body. + */ +function evaluatePolicy( + eventId: string, + pubkey: string, + _kind: number, +): PolicyDecision { + void pubkey; // will be used in Task #37 whitelist check + + return reject( + eventId, + "relay not yet open — whitelist pending (Task #37)", + ); +} + +export default router; diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml index 02d762a..580d76d 100644 --- a/infrastructure/docker-compose.yml +++ b/infrastructure/docker-compose.yml @@ -66,6 +66,58 @@ services: networks: - node-net + # ── Nostr relay ────────────────────────────────────────────────────────────── + + relay-policy: + # Write-policy sidecar: receives strfry plugin decisions, forwards to API server. + # Started before strfry so the plugin script can immediately reach it. + build: + context: ./relay-policy + dockerfile: Dockerfile + container_name: relay-policy + restart: unless-stopped + environment: + - PORT=3080 + # Base URL of the Timmy API server (no trailing slash). + # Example: https://alexanderwhitestone.com + - RELAY_API_URL=${RELAY_API_URL:-} + # Shared secret — must match RELAY_POLICY_SECRET in the API server's env. + - RELAY_POLICY_SECRET=${RELAY_POLICY_SECRET:-} + networks: + - node-net + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3080/health"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 10s + + strfry: + # strfry — high-performance Nostr relay written in C++. + # All inbound events pass through the relay-policy sidecar before storage. + image: ghcr.io/hoytech/strfry:latest + container_name: strfry + restart: unless-stopped + depends_on: + relay-policy: + condition: service_healthy + volumes: + - strfry_data:/data/strfry-db + - ./strfry.conf:/etc/strfry.conf:ro + # Plugin bridge script: reads events from strfry stdin, calls relay-policy HTTP. + - ./relay-policy/plugin.sh:/usr/local/bin/relay-policy-plugin:ro + ports: + - "7777:7777" + command: ["strfry", "--config=/etc/strfry.conf", "relay"] + networks: + - node-net + healthcheck: + test: ["CMD", "sh", "-c", "echo >/dev/tcp/localhost/7777"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 15s + networks: node-net: driver: bridge @@ -89,3 +141,12 @@ volumes: type: none o: bind device: /data/lnbits + strfry_data: + # Persistent event database — survives container restarts and upgrades. + # Operator must create /data/strfry on the VPS before first launch: + # mkdir -p /data/strfry && chmod 700 /data/strfry + driver: local + driver_opts: + type: none + o: bind + device: /data/strfry diff --git a/infrastructure/ops.sh b/infrastructure/ops.sh index a551e06..c79f2c3 100755 --- a/infrastructure/ops.sh +++ b/infrastructure/ops.sh @@ -318,6 +318,29 @@ CONF bash "$INFRA_DIR/sweep.sh" ;; + relay:logs) + echo -e "${CYAN}Tailing strfry relay logs (Ctrl-C to stop)...${NC}" + docker compose logs -f --tail=100 strfry relay-policy + ;; + + relay:restart) + echo -e "${CYAN}Restarting Nostr relay services...${NC}" + docker compose restart relay-policy + sleep 2 + docker compose restart strfry + echo -e "${GREEN}Done — relay services restarted.${NC}" + docker compose ps relay-policy strfry + ;; + + relay:status) + echo -e "\n${CYAN}── Nostr relay ───────────────────────────────${NC}" + docker compose ps relay-policy strfry + echo -e "\n${CYAN}── relay-policy health ───────────────────────${NC}" + docker exec relay-policy wget -qO- http://localhost:3080/health 2>/dev/null \ + | (command -v jq >/dev/null 2>&1 && jq . || cat) \ + || echo "relay-policy not ready" + ;; + help|*) echo -e "\n${CYAN}Timmy Node operations:${NC}" echo "" @@ -335,6 +358,10 @@ CONF echo " bash ops.sh configure-sweep — interactively set address, thresholds, frequency" echo " bash ops.sh run-sweep — run sweep immediately (outside of cron schedule)" echo "" + echo " bash ops.sh relay:logs — tail strfry + relay-policy logs" + echo " bash ops.sh relay:restart — restart relay-policy then strfry (safe order)" + echo " bash ops.sh relay:status — show relay container status + health" + echo "" ;; esac diff --git a/infrastructure/relay-policy/.gitignore b/infrastructure/relay-policy/.gitignore new file mode 100644 index 0000000..320c107 --- /dev/null +++ b/infrastructure/relay-policy/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +package-lock.json diff --git a/infrastructure/relay-policy/Dockerfile b/infrastructure/relay-policy/Dockerfile new file mode 100644 index 0000000..25403e1 --- /dev/null +++ b/infrastructure/relay-policy/Dockerfile @@ -0,0 +1,18 @@ +## relay-policy sidecar — multi-stage build +## +## Stage 1: compile TypeScript to ES modules +## Stage 2: minimal runtime image (no dev deps, no compiler) + +FROM node:22-alpine AS builder +WORKDIR /app +COPY package.json tsconfig.json ./ +RUN npm install +COPY index.ts ./ +RUN npx tsc + +# ── runtime ────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS runtime +WORKDIR /app +COPY --from=builder /app/dist/index.js ./index.js +EXPOSE 3080 +CMD ["node", "index.js"] diff --git a/infrastructure/relay-policy/index.ts b/infrastructure/relay-policy/index.ts new file mode 100644 index 0000000..7ce010f --- /dev/null +++ b/infrastructure/relay-policy/index.ts @@ -0,0 +1,160 @@ +/** + * relay-policy — Nostr relay write-policy sidecar + * + * Receives strfry plugin decision requests (one JSON object per HTTP POST to + * /decide) and forwards them to the API server's /api/relay/policy endpoint. + * Translates the API server's structured response into the flat decision JSON + * that strfry's plugin protocol expects. + * + * Environment: + * PORT — HTTP port to listen on (default: 3080) + * RELAY_API_URL — base URL of the API server, no trailing slash + * e.g. https://alexanderwhitestone.com + * RELAY_POLICY_SECRET — shared secret; sent as Bearer token to the API + * so the /api/relay/policy route can verify origin + * + * strfry plugin stdin/stdout protocol: + * Input per event: {"event":{...},"receivedAt":N,"sourceType":"...","sourceInfo":"..."} + * Output per event: {"id":"","action":"accept|reject|shadowReject","msg":"..."} + */ + +import * as http from "http"; + +const PORT = parseInt(process.env["PORT"] ?? "3080", 10); +const API_URL = (process.env["RELAY_API_URL"] ?? "").replace(/\/$/, ""); +const SECRET = process.env["RELAY_POLICY_SECRET"] ?? ""; + +if (!API_URL) { + console.error("[relay-policy] RELAY_API_URL is not set — rejecting all events"); +} +if (!SECRET) { + console.warn("[relay-policy] RELAY_POLICY_SECRET is not set — all API calls will be unauthenticated"); +} + +// ── Decision types ──────────────────────────────────────────────────────────── + +type StrfryAction = "accept" | "reject" | "shadowReject"; + +interface StrfryInput { + event: { + id: string; + pubkey: string; + kind: number; + created_at: number; + tags: string[][]; + content: string; + sig: string; + }; + receivedAt: number; + sourceType: string; + sourceInfo: string; +} + +interface StrfryDecision { + id: string; + action: StrfryAction; + msg: string; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function rejectDecision(id: string, msg: string): StrfryDecision { + return { id, action: "reject", msg }; +} + +/** + * Forward the strfry event payload to the API server and return its decision. + * Falls back to reject if the API call fails or times out. + */ +async function fetchPolicyDecision(payload: StrfryInput): Promise { + const eventId = payload.event?.id ?? "unknown"; + + if (!API_URL) { + return rejectDecision(eventId, "relay-policy: no API URL configured"); + } + + const url = `${API_URL}/api/relay/policy`; + const headers: Record = { + "Content-Type": "application/json", + }; + if (SECRET) { + headers["Authorization"] = `Bearer ${SECRET}`; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5_000); + + try { + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(payload), + signal: controller.signal, + }); + clearTimeout(timer); + + if (!res.ok) { + console.error(`[relay-policy] API returned ${res.status} for event ${eventId.slice(0, 8)}`); + return rejectDecision(eventId, `policy API error: HTTP ${res.status}`); + } + + const json = await res.json() as { action?: StrfryAction; msg?: string }; + const action: StrfryAction = + json.action === "accept" || json.action === "shadowReject" ? json.action : "reject"; + + return { id: eventId, action, msg: json.msg ?? "" }; + } catch (err) { + clearTimeout(timer); + const reason = err instanceof Error ? err.message : String(err); + console.warn(`[relay-policy] policy fetch failed for ${eventId.slice(0, 8)}: ${reason}`); + return rejectDecision(eventId, `policy fetch failed: ${reason}`); + } +} + +// ── HTTP server ─────────────────────────────────────────────────────────────── + +const server = http.createServer(async (req, res) => { + const url = req.url ?? "/"; + const method = req.method ?? "GET"; + + // Health-check + if (method === "GET" && url === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, apiUrl: API_URL || null })); + return; + } + + // Decision endpoint — called by relay-policy-plugin shell script + if (method === "POST" && url === "/decide") { + let body = ""; + req.on("data", chunk => { body += chunk; }); + req.on("end", async () => { + let payload: StrfryInput; + try { + payload = JSON.parse(body) as StrfryInput; + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify(rejectDecision("unknown", "invalid JSON"))); + return; + } + + const decision = await fetchPolicyDecision(payload); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(decision)); + }); + return; + } + + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); +}); + +server.listen(PORT, "0.0.0.0", () => { + console.log(`[relay-policy] listening on :${PORT}`); + console.log(`[relay-policy] API URL: ${API_URL || "(not set — all events will be rejected)"}`); +}); + +server.on("error", (err) => { + console.error("[relay-policy] server error:", err); + process.exit(1); +}); diff --git a/infrastructure/relay-policy/package.json b/infrastructure/relay-policy/package.json new file mode 100644 index 0000000..d3bbdf9 --- /dev/null +++ b/infrastructure/relay-policy/package.json @@ -0,0 +1,14 @@ +{ + "name": "relay-policy", + "version": "1.0.0", + "description": "strfry write-policy sidecar for Timmy's sovereign Nostr relay", + "type": "module", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.8.0" + } +} diff --git a/infrastructure/relay-policy/plugin.sh b/infrastructure/relay-policy/plugin.sh new file mode 100755 index 0000000..8a41791 --- /dev/null +++ b/infrastructure/relay-policy/plugin.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +## +## relay-policy-plugin — strfry write-policy plugin +## +## strfry starts this script once and feeds it JSON lines on stdin (one per +## event). The script forwards each line to the relay-policy HTTP sidecar and +## echoes the sidecar's JSON decision to stdout. If the sidecar is unavailable +## the event is rejected with a safe fallback so the relay does not accept +## unapproved events during a transient outage. +## +## stdin format: {"event":{...},"receivedAt":N,"sourceType":"...","sourceInfo":"..."} +## stdout format: {"id":"","action":"accept|reject|shadowReject","msg":"..."} +## + +RELAY_POLICY_URL="${RELAY_POLICY_URL:-http://relay-policy:3080/decide}" + +while IFS= read -r line; do + # Extract event id for the fallback response — pure bash, no external tools. + event_id=$(printf '%s' "$line" \ + | grep -o '"id":"[^"]*"' \ + | head -1 \ + | sed 's/"id":"//; s/"//') + + decision=$(printf '%s' "$line" \ + | curl -sf --max-time 5 \ + -X POST "$RELAY_POLICY_URL" \ + -H "Content-Type: application/json" \ + --data-binary @- 2>/dev/null) + + if [[ -z "$decision" ]]; then + printf '{"id":"%s","action":"reject","msg":"policy service unavailable"}\n' \ + "${event_id:-unknown}" + else + printf '%s\n' "$decision" + fi +done diff --git a/infrastructure/relay-policy/tsconfig.json b/infrastructure/relay-policy/tsconfig.json new file mode 100644 index 0000000..038914b --- /dev/null +++ b/infrastructure/relay-policy/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "lib": ["ES2022"] + }, + "include": ["index.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/infrastructure/strfry.conf b/infrastructure/strfry.conf new file mode 100644 index 0000000..356c8c4 --- /dev/null +++ b/infrastructure/strfry.conf @@ -0,0 +1,63 @@ +## +## strfry.conf — Timmy's sovereign Nostr relay +## +## All events pass through the relay-policy sidecar before being accepted. +## No event is stored without explicit approval from the API server. +## + +db = "/data/strfry-db" + +relay { + bind = "0.0.0.0" + port = 7777 + + info { + name = "Timmy Relay" + description = "Timmy's sovereign Nostr relay — whitelist-only" + pubkey = "" + contact = "" + icon = "" + } + + # Maximum WebSocket payload size (bytes). + # 131072 = 128 KiB — generous for NIP-09 deletions but not abusable. + maxWebsocketPayloadSize = 131072 + + autoPingSeconds = 55 + enableTcpKeepalive = false + queryTimesliceBudgetMicroseconds = 10000 + maxFilterLimit = 500 + maxSubsPerConnection = 20 + + writePolicy { + # Plugin receives JSON lines on stdin, writes decision lines to stdout. + # The plugin script bridges each event to the relay-policy HTTP sidecar. + plugin = "/usr/local/bin/relay-policy-plugin" + } + + compression { + enabled = true + slidingWindow = true + } + + logging { + dumpInAll = false + dumpInEvents = false + dumpInReqs = false + dbScanPerf = false + quiet = false + } + + numThreads { + ingester = 3 + reqWorker = 3 + reqMonitor = 1 + negentropy = 2 + } + + # Accept events up to 64 KiB — NIP-01 compliant maximum. + maxEventSize = 65536 + + # Allow ephemeral events (kinds 20000–29999) — needed for NIP-04 DMs. + rejectEphemeral = false +}