diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..af83228 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +export RELAYS=wss://offchain.pub,wss://nostr.bitcoiner.social +# 0 = run probes on each /metrics scrape; set >0 for periodic background probing. +# Example: 300000 = every 5 minutes. +export PROBE_INTERVAL_MS=300000 +export PROBE_TIMEOUT_MS=10000 +export LISTEN_ADDR=0.0.0.0 +export PORT=9464 +export LOG_LEVEL=info + +# Optional write confirmation probe settings: +export WRITE_CHECK_ENABLED=true +export WRITE_CHECK_KIND=30078 +# WRITE_CHECK_PRIVKEY accepts either: +# - nsec1... string +# - 64-char hex private key +# If omitted or invalid, relay-exporter generates an ephemeral key at runtime. +#export WRITE_CHECK_PRIVKEY=nsec1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/.gitignore b/.gitignore index 4464c3f..7e2dfc5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules .wrangler /.svelte-kit /build +/dist # OS .DS_Store @@ -24,4 +25,5 @@ vite.config.ts.timestamp-* .data .env -archive \ No newline at end of file +archive +result \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..28bdc93 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# relay-exporter + +Production-oriented Prometheus exporter for monitoring a fixed set of Nostr relays. + +## What it does + +- Probes each configured relay either on an interval, or on-demand when `/metrics` is scraped. +- Uses `@nostrwatch/nocap` for `open` + `read` checks. +- Optionally performs a low-noise write confirmation check (kind `30078` by default) using deterministic `d` tags. +- Exposes: + - `/metrics` for Prometheus scraping + - `/healthz` for process and probe-loop health +- Publishes default process metrics via `prom-client`. + +## Install + +```bash +pnpm install +``` + +If you are bootstrapping from scratch, these are the exact dependency commands: + +```bash +pnpm add @nostrwatch/nocap prom-client nostr-tools p-limit +pnpm add -D typescript tsx @types/node +``` + +## Configuration + +Copy and edit: + +```bash +cp .env.example .env +``` + +Example: run probes every 5 minutes instead of on each scrape: + +```bash +export PROBE_INTERVAL_MS=300000 +``` + +Environment variables: + +- `RELAYS` (required): comma-separated `wss://` URLs +- `PROBE_INTERVAL_MS` (default: `0`; `0` means run probes on each `/metrics` scrape) +- `PROBE_TIMEOUT_MS` (default: `10000`) +- `LISTEN_ADDR` (default: `0.0.0.0`) +- `PORT` (default: `9464`) +- `LOG_LEVEL` (default: `info`; one of `debug|info|warn|error`) +- `WRITE_CHECK_ENABLED` (default: `true`) +- `WRITE_CHECK_KIND` (default: `30078`) +- `WRITE_CHECK_PRIVKEY` (optional; supports `nsec1...` or 64-char hex) + +### Write confirmation key material + +- `WRITE_CHECK_PRIVKEY` may be an `nsec1...` value or a 64-character hex private key. +- `WRITE_CHECK_PUBKEY` is not needed; write-check pubkey is always derived from the private key. +- If `WRITE_CHECK_PRIVKEY` is missing or invalid and write checks are enabled, the exporter generates an ephemeral key for the running process and continues write checks. +- Private key values are never logged. + +## Run + +Development with auto-reload: + +```bash +pnpm dev +``` + +Production build: + +```bash +pnpm build +pnpm start +``` + +Run tests: + +```bash +pnpm test +``` + +## Exposed metrics + +Relay-level labels use `{relay}` unless stated: + +- `nostr_relay_up` (gauge) +- `nostr_relay_open_ok` (gauge) +- `nostr_relay_read_ok` (gauge) +- `nostr_relay_write_confirm_ok` (gauge) +- `nostr_relay_open_duration_ms` (gauge) +- `nostr_relay_read_duration_ms` (gauge) +- `nostr_relay_write_duration_ms` (gauge, `-1` when unavailable/disabled) +- `nostr_relay_last_success_unixtime` (gauge) +- `nostr_relay_probe_errors_total{relay,check}` (counter) +- `nostr_relay_probe_runs_total{relay,result}` (counter; `result=success|failure`) + +Also includes all default Node.js process/runtime metrics from `prom-client`. + +## Prometheus scrape config example + +```yaml +scrape_configs: + - job_name: nostr-relay-exporter + scrape_interval: 15s + metrics_path: /metrics + static_configs: + - targets: + - "relay-exporter.internal:9464" +``` + +## Example Grafana queries + +- Relay up status by relay: + - `max by (relay) (nostr_relay_up)` +- Open latency: + - `avg_over_time(nostr_relay_open_duration_ms[5m])` +- Read latency: + - `avg_over_time(nostr_relay_read_duration_ms[5m])` +- Write confirmation success ratio (15m): + - `sum by (relay) (increase(nostr_relay_probe_runs_total{result="success"}[15m])) / sum by (relay) (increase(nostr_relay_probe_runs_total[15m]))` +- Probe errors by check: + - `sum by (relay, check) (increase(nostr_relay_probe_errors_total[15m]))` + +## Health endpoint + +- `GET /healthz` returns: + - `200` when process is running and probe data is fresh enough + - `503` when shutting down or probe data is stale/not yet available + +## Notes + +- Relay probes are isolated; one relay failure does not block others. +- `nocap` default adapters are explicitly loaded before checks. +- Probe concurrency is bounded in code (`DEFAULT_PROBE_CONCURRENCY` in `src/config.ts`). +- Graceful shutdown handles `SIGINT` and `SIGTERM`. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..a7c01d2 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1773964973, + "narHash": "sha256-NV/J+tTER0P5iJhUDL/8HO5MDjDceLQPRUYgdmy5wXw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "812b3986fd1568f7a858f97fcf425ad996ba7d25", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..ba6d164 --- /dev/null +++ b/flake.nix @@ -0,0 +1,174 @@ +{ + description = "Nostr relay Prometheus exporter"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + }; + + outputs = { self, nixpkgs }: + let + inherit (nixpkgs.lib) genAttrs; + systems = [ "x86_64-linux" "aarch64-linux" ]; + forAllSystems = genAttrs systems; + + mkPackage = pkgs: + let + pname = "relay-exporter"; + version = "0.1.0"; + src = pkgs.lib.cleanSourceWith { + src = ./.; + filter = path: type: + !(pkgs.lib.hasInfix "/.git/" path) + && !(pkgs.lib.hasInfix "/node_modules/" path) + && !(pkgs.lib.hasInfix "/dist/" path); + }; + in + pkgs.stdenv.mkDerivation { + inherit pname version; + inherit src; + + nativeBuildInputs = [ + pkgs.nodejs_22 + pkgs.pnpm.configHook + pkgs.makeWrapper + ]; + + pnpmDeps = pkgs.pnpm.fetchDeps { + inherit pname version src; + fetcherVersion = 1; + hash = "sha256-n3pn4NVBVauwE2FWfMsulmn+KgCkcV32XJD6vX1NIB0="; + }; + + buildPhase = '' + runHook preBuild + pnpm run build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out/app $out/bin + cp -r dist package.json node_modules $out/app/ + makeWrapper ${pkgs.nodejs_22}/bin/node $out/bin/relay-exporter \ + --set NODE_ENV production \ + --add-flags "$out/app/dist/index.js" + runHook postInstall + ''; + + meta = { + description = "Prometheus exporter for Nostr relay health checks"; + mainProgram = "relay-exporter"; + license = pkgs.lib.licenses.mit; + platforms = pkgs.lib.platforms.linux; + }; + }; + in + rec { + packages = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + pkg = mkPackage pkgs; + in + { + relay-exporter = pkg; + default = pkg; + }); + + devShells = forAllSystems (system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + default = pkgs.mkShell { + packages = [ + pkgs.nodejs_22 + pkgs.pnpm_10 + ]; + }; + }); + + overlays.default = final: prev: { + relay-exporter = self.packages.${prev.stdenv.hostPlatform.system}.relay-exporter; + }; + + nixosModules.default = + { config, lib, pkgs, ... }: + let + inherit (lib) mkEnableOption mkIf mkOption types; + cfg = config.services.relay-exporter; + system = pkgs.stdenv.hostPlatform.system; + defaultPackage = self.packages.${system}.relay-exporter; + in + { + options.services.relay-exporter = { + enable = mkEnableOption "relay-exporter service"; + + package = mkOption { + type = types.package; + default = defaultPackage; + defaultText = "self.packages.${system}.relay-exporter"; + description = "Derivation that provides relay-exporter."; + }; + + port = mkOption { + type = types.port; + default = 9464; + description = "HTTP listen port for relay-exporter."; + }; + + listenAddr = mkOption { + type = types.str; + default = "0.0.0.0"; + description = "Listen address for relay-exporter."; + }; + + stateDirectory = mkOption { + type = types.str; + default = "relay-exporter"; + description = "Name of the state directory managed by systemd."; + }; + + environment = mkOption { + type = types.attrsOf types.str; + default = { }; + description = "Additional environment variables for relay-exporter."; + }; + + environmentFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Optional environment file consumed by systemd."; + }; + }; + + config = mkIf cfg.enable { + systemd.services.relay-exporter = { + description = "Nostr relay Prometheus exporter"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + + environment = { + NODE_ENV = "production"; + PORT = builtins.toString cfg.port; + LISTEN_ADDR = cfg.listenAddr; + } // cfg.environment; + + serviceConfig = + { + Type = "simple"; + ExecStart = "${cfg.package}/bin/relay-exporter"; + DynamicUser = true; + StateDirectory = cfg.stateDirectory; + WorkingDirectory = "/var/lib/${cfg.stateDirectory}"; + Restart = "on-failure"; + RestartSec = 3; + } + // lib.optionalAttrs (cfg.environmentFile != null) { + EnvironmentFile = cfg.environmentFile; + }; + }; + }; + }; + }; +} diff --git a/package.json b/package.json index 3ccce5f..beef9cc 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,27 @@ { "name": "relay-exporter", "version": "1.0.0", - "description": "", - "main": "index.js", + "description": "Prometheus exporter for Nostr relay health checks", + "type": "module", + "main": "dist/index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "test": "tsx --test test/config.test.ts test/smoke.test.ts" }, - "keywords": [], - "author": "", - "license": "ISC", + "keywords": [ + "nostr", + "prometheus", + "exporter" + ], + "license": "MIT", "packageManager": "pnpm@10.28.0", "dependencies": { "@nostrwatch/nocap": "^0.9.2", + "@nostrwatch/nocap-every-adapter-default": "^1.7.0", + "nostr-tools": "^2.23.3", + "p-limit": "^7.3.0", "prom-client": "^15.1.3" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63ae8b7..0b46292 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,15 @@ importers: '@nostrwatch/nocap': specifier: ^0.9.2 version: 0.9.2(@types/node@25.5.0)(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vitest@0.32.4) + '@nostrwatch/nocap-every-adapter-default': + specifier: ^1.7.0 + version: 1.7.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + nostr-tools: + specifier: ^2.23.3 + version: 2.23.3(typescript@5.9.3) + p-limit: + specifier: ^7.3.0 + version: 7.3.0 prom-client: specifier: ^15.1.3 version: 15.1.3 @@ -570,6 +579,18 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nostrwatch/logger@0.0.9': resolution: {integrity: sha512-G/Z4E9/vtJhP7bRu0WA8bXE+HiFTLO8bVWCT34RZnW7/W6fWOxoAbTt5fhqrLwzYVtVWcfHwF2fiDidaVhqsQA==} @@ -603,6 +624,15 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@scure/base@2.0.0': + resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==} + + '@scure/bip32@2.0.1': + resolution: {integrity: sha512-4Md1NI5BzoVP+bhyJaY3K6yMesEFzNS1sE/cP+9nuvE7p/b0kx9XbpDHHFl8dHtufcbdHRUUQdRqLIPHN/s7yA==} + + '@scure/bip39@2.0.1': + resolution: {integrity: sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg==} + '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} @@ -1401,6 +1431,17 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + nostr-tools@2.23.3: + resolution: {integrity: sha512-AALyt9k8xPdF4UV2mlLJ2mgCn4kpTB0DZ8t2r6wjdUh6anfx2cTVBsHUlo9U0EY/cKC5wcNyiMAmRJV5OVEalA==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + nostr-wasm@0.1.0: + resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -1428,6 +1469,10 @@ packages: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-limit@7.3.0: + resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==} + engines: {node: '>=20'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -2465,6 +2510,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@noble/ciphers@2.1.1': {} + + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + + '@noble/hashes@2.0.1': {} + '@nostrwatch/logger@0.0.9': dependencies: logging: 3.3.0 @@ -2552,6 +2605,19 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@scure/base@2.0.0': {} + + '@scure/bip32@2.0.1': + dependencies: + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + + '@scure/bip39@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@sinclair/typebox@0.27.10': {} '@sinonjs/commons@3.0.1': @@ -3584,6 +3650,20 @@ snapshots: normalize-path@3.0.0: {} + nostr-tools@2.23.3(typescript@5.9.3): + dependencies: + '@noble/ciphers': 2.1.1 + '@noble/curves': 2.0.1 + '@noble/hashes': 2.0.1 + '@scure/base': 2.0.0 + '@scure/bip32': 2.0.1 + '@scure/bip39': 2.0.1 + nostr-wasm: 0.1.0 + optionalDependencies: + typescript: 5.9.3 + + nostr-wasm@0.1.0: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -3610,6 +3690,10 @@ snapshots: dependencies: yocto-queue: 1.2.2 + p-limit@7.3.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3c76fdd --- /dev/null +++ b/src/config.ts @@ -0,0 +1,216 @@ +import { URL } from "node:url"; +import { generateSecretKey, nip19 } from "nostr-tools"; + +export type LogLevel = "debug" | "info" | "warn" | "error"; + +export interface RelayTarget { + relay: string; + host: string; +} + +export interface WriteCheckConfig { + enabled: boolean; + kind: number; + privkey?: string; +} + +export interface AppConfig { + relays: RelayTarget[]; + probeIntervalMs: number; + probeTimeoutMs: number; + listenAddr: string; + port: number; + logLevel: LogLevel; + probeConcurrency: number; + staleAfterMs: number; + writeCheck: WriteCheckConfig; + warnings: string[]; +} + +const DEFAULT_PROBE_INTERVAL_MS = 0; +const DEFAULT_PROBE_TIMEOUT_MS = 10_000; +const DEFAULT_LISTEN_ADDR = "0.0.0.0"; +const DEFAULT_PORT = 9464; +const DEFAULT_LOG_LEVEL: LogLevel = "info"; +const DEFAULT_WRITE_CHECK_ENABLED = true; +const DEFAULT_WRITE_CHECK_KIND = 30078; + +const MIN_TIMEOUT_MS = 1_000; +const MIN_PORT = 1; +const MAX_PORT = 65_535; + +// Kept as a constant so operators can tune by editing code. +export const DEFAULT_PROBE_CONCURRENCY = 4; + +function parseBoolean(raw: string | undefined, fallback: boolean): boolean { + if (raw === undefined || raw.trim() === "") return fallback; + const value = raw.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(value)) return true; + if (["0", "false", "no", "off"].includes(value)) return false; + return fallback; +} + +function parseInteger( + raw: string | undefined, + fallback: number, + min: number, + name: string, +): number { + if (raw === undefined || raw.trim() === "") return fallback; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || Number.isNaN(parsed)) { + throw new Error(`${name} must be an integer`); + } + if (parsed < min) { + throw new Error(`${name} must be >= ${min}`); + } + return parsed; +} + +function parseLogLevel(raw: string | undefined): LogLevel { + if (!raw) return DEFAULT_LOG_LEVEL; + const normalized = raw.trim().toLowerCase(); + if ( + normalized === "debug" || + normalized === "info" || + normalized === "warn" || + normalized === "error" + ) { + return normalized; + } + throw new Error("LOG_LEVEL must be one of: debug, info, warn, error"); +} + +function parseRelay(rawRelay: string): RelayTarget | null { + const relayValue = rawRelay.trim(); + if (!relayValue) return null; + let relayUrl: URL; + try { + relayUrl = new URL(relayValue); + } catch { + return null; + } + if (relayUrl.protocol !== "wss:") return null; + return { + relay: relayUrl.toString(), + host: relayUrl.hostname, + }; +} + +function parseRelays(raw: string): { relays: RelayTarget[]; warnings: string[] } { + const warnings: string[] = []; + const unique = new Set(); + const relays: RelayTarget[] = []; + + for (const chunk of raw.split(",")) { + const parsed = parseRelay(chunk); + if (!parsed) { + const clean = chunk.trim(); + if (clean) warnings.push(`Ignoring invalid relay URL: ${clean}`); + continue; + } + if (unique.has(parsed.relay)) continue; + unique.add(parsed.relay); + relays.push(parsed); + } + + return { relays, warnings }; +} + +function isHex64(value: string): boolean { + return /^[0-9a-f]{64}$/i.test(value); +} + +function isValidWriteCheckPrivkey(raw: string): boolean { + const value = raw.trim(); + if (value.startsWith("nsec1")) { + try { + const decoded = nip19.decode(value); + return decoded.type === "nsec"; + } catch { + return false; + } + } + return isHex64(value); +} + +function generateEphemeralWriteCheckPrivkey(): string { + return Buffer.from(generateSecretKey()).toString("hex"); +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { + const relaysRaw = env.RELAYS; + if (!relaysRaw || relaysRaw.trim() === "") { + throw new Error("RELAYS is required (comma-separated list of wss:// URLs)"); + } + + const { relays, warnings } = parseRelays(relaysRaw); + if (relays.length === 0) { + throw new Error("RELAYS did not contain any valid wss:// relay URLs"); + } + + const probeIntervalMs = parseInteger( + env.PROBE_INTERVAL_MS, + DEFAULT_PROBE_INTERVAL_MS, + 0, + "PROBE_INTERVAL_MS", + ); + const probeTimeoutMs = parseInteger( + env.PROBE_TIMEOUT_MS, + DEFAULT_PROBE_TIMEOUT_MS, + MIN_TIMEOUT_MS, + "PROBE_TIMEOUT_MS", + ); + const port = parseInteger(env.PORT, DEFAULT_PORT, MIN_PORT, "PORT"); + if (port > MAX_PORT) { + throw new Error(`PORT must be <= ${MAX_PORT}`); + } + + const writeEnabledFlag = parseBoolean(env.WRITE_CHECK_ENABLED, DEFAULT_WRITE_CHECK_ENABLED); + const writeCheckKind = parseInteger( + env.WRITE_CHECK_KIND, + DEFAULT_WRITE_CHECK_KIND, + 0, + "WRITE_CHECK_KIND", + ); + const writePrivkey = env.WRITE_CHECK_PRIVKEY?.trim(); + let resolvedWritePrivkey = writePrivkey; + if (env.WRITE_CHECK_PUBKEY?.trim()) { + warnings.push( + "WRITE_CHECK_PUBKEY is ignored; write-check pubkey is always derived from WRITE_CHECK_PRIVKEY", + ); + } + if (writeEnabledFlag) { + if (!writePrivkey) { + resolvedWritePrivkey = generateEphemeralWriteCheckPrivkey(); + warnings.push( + "WRITE_CHECK_PRIVKEY not set; generated ephemeral write-check key for this process", + ); + } else if (!isValidWriteCheckPrivkey(writePrivkey)) { + resolvedWritePrivkey = generateEphemeralWriteCheckPrivkey(); + warnings.push( + "WRITE_CHECK_PRIVKEY invalid; generated ephemeral write-check key for this process", + ); + } + } + + const staleAfterMs = probeIntervalMs + probeTimeoutMs + 5_000; + const writeCheck: WriteCheckConfig = { + enabled: writeEnabledFlag, + kind: writeCheckKind, + ...(resolvedWritePrivkey ? { privkey: resolvedWritePrivkey } : {}), + }; + + return { + relays, + probeIntervalMs, + probeTimeoutMs, + listenAddr: env.LISTEN_ADDR?.trim() || DEFAULT_LISTEN_ADDR, + port, + logLevel: parseLogLevel(env.LOG_LEVEL), + probeConcurrency: DEFAULT_PROBE_CONCURRENCY, + staleAfterMs, + writeCheck, + warnings, + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8d1eeeb --- /dev/null +++ b/src/index.ts @@ -0,0 +1,147 @@ +import http from "node:http"; +import process from "node:process"; + +import { loadConfig, type LogLevel } from "./config.js"; +import { RelayMetrics } from "./metrics.js"; +import { RelayProber, type Logger } from "./prober.js"; + +const LOG_LEVEL_ORDER: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, +}; + +function sanitizeFields(fields: Record): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(fields)) { + if (key.toLowerCase().includes("privkey") || key.toLowerCase().includes("secret")) { + sanitized[key] = "[redacted]"; + continue; + } + sanitized[key] = value; + } + return sanitized; +} + +function createLogger(level: LogLevel): Logger { + const minLevel = LOG_LEVEL_ORDER[level]; + + const emit = (lvl: LogLevel, fields: Record, message: string): void => { + if (LOG_LEVEL_ORDER[lvl] < minLevel) return; + const line = { + ts: new Date().toISOString(), + level: lvl, + msg: message, + ...sanitizeFields(fields), + }; + process.stdout.write(`${JSON.stringify(line)}\n`); + }; + + return { + debug: (fields, message) => emit("debug", fields, message), + info: (fields, message) => emit("info", fields, message), + warn: (fields, message) => emit("warn", fields, message), + error: (fields, message) => emit("error", fields, message), + }; +} + +async function main(): Promise { + const config = loadConfig(); + const logger = createLogger(config.logLevel); + for (const warning of config.warnings) { + logger.warn({ component: "config" }, warning); + } + + const metrics = new RelayMetrics(); + const prober = new RelayProber(config, metrics, logger); + prober.start(); + + let shuttingDown = false; + const server = http.createServer(async (req, res) => { + if (!req.url) { + res.statusCode = 400; + res.end("bad request"); + return; + } + + if (req.url === "/metrics") { + if (config.probeIntervalMs <= 0) { + await prober.runOnDemand(); + } + const nowUnix = Math.floor(Date.now() / 1000); + prober.markStale(nowUnix); + res.statusCode = 200; + res.setHeader("Content-Type", metrics.registry.contentType); + res.end(await metrics.registry.metrics()); + return; + } + + if (req.url === "/healthz") { + const nowUnix = Math.floor(Date.now() / 1000); + const health = prober.health(nowUnix); + const ok = !shuttingDown && health.ok; + const body = JSON.stringify( + { + status: ok ? "ok" : "degraded", + shuttingDown, + maxLastProbeAgeMs: health.minLastProbeAgeMs, + }, + null, + 2, + ); + res.statusCode = ok ? 200 : 503; + res.setHeader("Content-Type", "application/json"); + res.end(body); + return; + } + + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain"); + res.end("not found"); + }); + + const shutdown = (signal: NodeJS.Signals): void => { + if (shuttingDown) return; + shuttingDown = true; + logger.info({ signal }, "shutdown requested"); + prober.stop(); + + const forceExitTimer = setTimeout(() => { + logger.error({ signal }, "forced shutdown after timeout"); + process.exit(1); + }, 10_000); + + server.close((error?: Error) => { + clearTimeout(forceExitTimer); + if (error) { + logger.error({ error: error.message }, "http server close failed"); + process.exit(1); + return; + } + logger.info({ signal }, "shutdown complete"); + process.exit(0); + }); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + server.listen(config.port, config.listenAddr, () => { + logger.info( + { + listenAddr: config.listenAddr, + port: config.port, + relays: config.relays.length, + writeCheckEnabled: config.writeCheck.enabled, + }, + "relay exporter started", + ); + }); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : "startup failure"; + process.stderr.write(`${message}\n`); + process.exit(1); +}); diff --git a/src/metrics.ts b/src/metrics.ts new file mode 100644 index 0000000..6ab19e1 --- /dev/null +++ b/src/metrics.ts @@ -0,0 +1,130 @@ +import { Counter, Gauge, Registry, collectDefaultMetrics } from "prom-client"; + +export interface RelayProbeUpdate { + relay: string; + openOk: boolean; + readOk: boolean; + writeConfirmOk: boolean; + up: boolean; + openDurationMs: number; + readDurationMs: number; + writeDurationMs?: number; + lastSuccessUnix?: number; +} + +export class RelayMetrics { + public readonly registry: Registry; + + private readonly relayUp: Gauge<"relay">; + private readonly relayOpenOk: Gauge<"relay">; + private readonly relayReadOk: Gauge<"relay">; + private readonly relayWriteConfirmOk: Gauge<"relay">; + private readonly relayOpenDurationMs: Gauge<"relay">; + private readonly relayReadDurationMs: Gauge<"relay">; + private readonly relayWriteDurationMs: Gauge<"relay">; + private readonly relayLastSuccessUnix: Gauge<"relay">; + private readonly relayProbeErrorsTotal: Counter<"relay" | "check">; + private readonly relayProbeRunsTotal: Counter<"relay" | "result">; + + constructor() { + this.registry = new Registry(); + collectDefaultMetrics({ register: this.registry }); + + this.relayUp = new Gauge({ + name: "nostr_relay_up", + help: "1 if open+read(+write confirm when enabled) succeeds, else 0", + labelNames: ["relay"], + registers: [this.registry], + }); + this.relayOpenOk = new Gauge({ + name: "nostr_relay_open_ok", + help: "Relay open check status", + labelNames: ["relay"], + registers: [this.registry], + }); + this.relayReadOk = new Gauge({ + name: "nostr_relay_read_ok", + help: "Relay read check status", + labelNames: ["relay"], + registers: [this.registry], + }); + this.relayWriteConfirmOk = new Gauge({ + name: "nostr_relay_write_confirm_ok", + help: "Relay write+read-after-write confirmation status", + labelNames: ["relay"], + registers: [this.registry], + }); + this.relayOpenDurationMs = new Gauge({ + name: "nostr_relay_open_duration_ms", + help: "Relay open check duration in milliseconds", + labelNames: ["relay"], + registers: [this.registry], + }); + this.relayReadDurationMs = new Gauge({ + name: "nostr_relay_read_duration_ms", + help: "Relay read check duration in milliseconds", + labelNames: ["relay"], + registers: [this.registry], + }); + this.relayWriteDurationMs = new Gauge({ + name: "nostr_relay_write_duration_ms", + help: "Relay write-confirm duration in milliseconds", + labelNames: ["relay"], + registers: [this.registry], + }); + this.relayLastSuccessUnix = new Gauge({ + name: "nostr_relay_last_success_unixtime", + help: "Unix timestamp of the last successful full probe", + labelNames: ["relay"], + registers: [this.registry], + }); + this.relayProbeErrorsTotal = new Counter({ + name: "nostr_relay_probe_errors_total", + help: "Total relay probe errors by check", + labelNames: ["relay", "check"], + registers: [this.registry], + }); + this.relayProbeRunsTotal = new Counter({ + name: "nostr_relay_probe_runs_total", + help: "Total relay probe runs by final result", + labelNames: ["relay", "result"], + registers: [this.registry], + }); + } + + public initializeRelay(relay: string): void { + this.relayUp.labels(relay).set(0); + this.relayOpenOk.labels(relay).set(0); + this.relayReadOk.labels(relay).set(0); + this.relayWriteConfirmOk.labels(relay).set(0); + this.relayOpenDurationMs.labels(relay).set(-1); + this.relayReadDurationMs.labels(relay).set(-1); + this.relayWriteDurationMs.labels(relay).set(-1); + this.relayLastSuccessUnix.labels(relay).set(0); + } + + public applyUpdate(update: RelayProbeUpdate): void { + this.relayUp.labels(update.relay).set(update.up ? 1 : 0); + this.relayOpenOk.labels(update.relay).set(update.openOk ? 1 : 0); + this.relayReadOk.labels(update.relay).set(update.readOk ? 1 : 0); + this.relayWriteConfirmOk.labels(update.relay).set(update.writeConfirmOk ? 1 : 0); + this.relayOpenDurationMs.labels(update.relay).set(update.openDurationMs); + this.relayReadDurationMs.labels(update.relay).set(update.readDurationMs); + this.relayWriteDurationMs.labels(update.relay).set(update.writeDurationMs ?? -1); + if (update.lastSuccessUnix !== undefined) { + this.relayLastSuccessUnix.labels(update.relay).set(update.lastSuccessUnix); + } + } + + public markStale(relay: string): void { + this.relayUp.labels(relay).set(0); + } + + public incProbeError(relay: string, check: string): void { + this.relayProbeErrorsTotal.labels(relay, check).inc(); + } + + public incProbeRun(relay: string, result: "success" | "failure"): void { + this.relayProbeRunsTotal.labels(relay, result).inc(); + } +} diff --git a/src/nocap-adapters.d.ts b/src/nocap-adapters.d.ts new file mode 100644 index 0000000..c5dd75d --- /dev/null +++ b/src/nocap-adapters.d.ts @@ -0,0 +1,4 @@ +declare module "@nostrwatch/nocap-every-adapter-default" { + const adapters: Record unknown>; + export default adapters; +} diff --git a/src/nocap.d.ts b/src/nocap.d.ts new file mode 100644 index 0000000..1c9aca5 --- /dev/null +++ b/src/nocap.d.ts @@ -0,0 +1,7 @@ +declare module "@nostrwatch/nocap" { + export class Nocap { + constructor(url: string, config?: unknown); + check(keys: string[], headers?: boolean): Promise; + useAdapters(adapters: unknown[]): Promise; + } +} diff --git a/src/prober.ts b/src/prober.ts new file mode 100644 index 0000000..a05c8c8 --- /dev/null +++ b/src/prober.ts @@ -0,0 +1,296 @@ +import pLimit from "p-limit"; +import { Nocap } from "@nostrwatch/nocap"; +import NocapEveryAdapterDefault from "@nostrwatch/nocap-every-adapter-default"; + +import type { AppConfig, RelayTarget } from "./config.js"; +import type { RelayMetrics } from "./metrics.js"; +import { runWriteConfirm } from "./writeConfirm.js"; + +export interface Logger { + debug(fields: Record, message: string): void; + info(fields: Record, message: string): void; + warn(fields: Record, message: string): void; + error(fields: Record, message: string): void; +} + +interface RelayCheckNode { + data?: boolean; + duration?: number; + status?: string; + message?: string; +} + +interface NocapResult { + open?: RelayCheckNode; + read?: RelayCheckNode; +} + +interface RelayProbeState { + lastProbeUnix?: number; + lastSuccessUnix?: number; +} + +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((error: unknown) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +function durationOrDefault(value: number | undefined): number { + if (typeof value === "number" && Number.isFinite(value) && value >= 0) { + return value; + } + return -1; +} + +function messageOf(error: unknown): string { + if (error instanceof Error) return error.message; + return "unknown error"; +} + +export class RelayProber { + private timer: NodeJS.Timeout | null = null; + private running = false; + private rerunRequested = false; + private stopped = false; + private readonly states = new Map(); + + constructor( + private readonly config: AppConfig, + private readonly metrics: RelayMetrics, + private readonly logger: Logger, + ) { + for (const relay of this.config.relays) { + this.metrics.initializeRelay(relay.relay); + this.states.set(relay.relay, {}); + } + } + + public start(): void { + if (this.config.probeIntervalMs <= 0) { + this.logger.info( + { probeIntervalMs: this.config.probeIntervalMs }, + "probe interval disabled; probes run on /metrics scrape", + ); + return; + } + void this.runCycle(); + this.timer = setInterval(() => { + void this.runCycle(); + }, this.config.probeIntervalMs); + } + + public async runOnDemand(): Promise { + await this.runCycle(); + } + + public stop(): void { + this.stopped = true; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + public markStale(nowUnix: number): void { + for (const relay of this.config.relays) { + const state = this.states.get(relay.relay); + if (!state?.lastProbeUnix) { + this.metrics.markStale(relay.relay); + continue; + } + const ageMs = nowUnix * 1000 - state.lastProbeUnix * 1000; + if (ageMs > this.config.staleAfterMs) { + this.metrics.markStale(relay.relay); + } + } + } + + public health(nowUnix: number): { ok: boolean; minLastProbeAgeMs: number | null } { + let maxAgeMs = 0; + let seen = false; + for (const relay of this.config.relays) { + const state = this.states.get(relay.relay); + if (!state?.lastProbeUnix) continue; + seen = true; + const age = nowUnix * 1000 - state.lastProbeUnix * 1000; + if (age > maxAgeMs) maxAgeMs = age; + } + if (!seen) return { ok: false, minLastProbeAgeMs: null }; + return { ok: maxAgeMs <= this.config.staleAfterMs, minLastProbeAgeMs: maxAgeMs }; + } + + private async runCycle(): Promise { + if (this.stopped) return; + if (this.running) { + this.rerunRequested = true; + return; + } + + this.running = true; + const cycleStarted = Date.now(); + this.logger.debug( + { + relayCount: this.config.relays.length, + concurrency: this.config.probeConcurrency, + }, + "probe cycle started", + ); + + try { + const limit = pLimit(this.config.probeConcurrency); + await Promise.allSettled( + this.config.relays.map((relay) => + limit(async () => { + await this.probeRelay(relay); + }), + ), + ); + } finally { + this.running = false; + this.logger.debug( + { durationMs: Date.now() - cycleStarted, rerunRequested: this.rerunRequested }, + "probe cycle finished", + ); + if (this.rerunRequested && !this.stopped) { + this.rerunRequested = false; + void this.runCycle(); + } + } + } + + private async probeRelay(relay: RelayTarget): Promise { + const relayLabel = relay.relay; + const probeStarted = Date.now(); + const state = this.states.get(relayLabel) ?? {}; + + let openOk = false; + let readOk = false; + let writeConfirmOk = !this.config.writeCheck.enabled; + let openDurationMs = -1; + let readDurationMs = -1; + let writeDurationMs = -1; + + try { + const nocap = new Nocap(relay.relay, { + logLevel: this.config.logLevel, + timeout: { + open: this.config.probeTimeoutMs, + read: this.config.probeTimeoutMs, + }, + failAllChecksOnConnectFailure: true, + }); + const adapters = Object.values(NocapEveryAdapterDefault); + await nocap.useAdapters(adapters); + + const result = (await withTimeout( + nocap.check(["open", "read"], true) as Promise, + this.config.probeTimeoutMs * 2, + "nocap open/read", + )) as NocapResult; + + openOk = Boolean(result.open?.data); + readOk = Boolean(result.read?.data); + openDurationMs = durationOrDefault(result.open?.duration); + readDurationMs = durationOrDefault(result.read?.duration); + + if (!openOk) { + this.metrics.incProbeError(relayLabel, "open"); + } + if (!readOk) { + this.metrics.incProbeError(relayLabel, "read"); + } + + if (this.config.writeCheck.enabled) { + const privkey = this.config.writeCheck.privkey; + if (!privkey) { + writeConfirmOk = false; + this.metrics.incProbeError(relayLabel, "write_confirm"); + } else { + const writeResult = await runWriteConfirm({ + relay: relay.relay, + host: relay.host, + kind: this.config.writeCheck.kind, + privkey, + timeoutMs: this.config.probeTimeoutMs, + }); + writeConfirmOk = writeResult.ok; + writeDurationMs = durationOrDefault(writeResult.durationMs); + if (!writeResult.ok) { + this.metrics.incProbeError(relayLabel, "write_confirm"); + this.logger.warn( + { relay: relayLabel, check: "write_confirm", reason: writeResult.reason }, + "write confirmation failed", + ); + } + } + } + + const up = openOk && readOk && writeConfirmOk; + const nowUnix = Math.floor(Date.now() / 1000); + state.lastProbeUnix = nowUnix; + if (up) { + state.lastSuccessUnix = nowUnix; + this.metrics.incProbeRun(relayLabel, "success"); + } else { + this.metrics.incProbeRun(relayLabel, "failure"); + } + this.states.set(relayLabel, state); + + this.metrics.applyUpdate({ + relay: relayLabel, + openOk, + readOk, + writeConfirmOk, + up, + openDurationMs, + readDurationMs, + writeDurationMs, + ...(state.lastSuccessUnix !== undefined ? { lastSuccessUnix: state.lastSuccessUnix } : {}), + }); + + this.logger.info( + { + relay: relayLabel, + up, + openOk, + readOk, + writeConfirmOk, + durationMs: Date.now() - probeStarted, + }, + "relay probe completed", + ); + } catch (error: unknown) { + const nowUnix = Math.floor(Date.now() / 1000); + state.lastProbeUnix = nowUnix; + this.states.set(relayLabel, state); + + this.metrics.incProbeError(relayLabel, "probe"); + this.metrics.incProbeRun(relayLabel, "failure"); + this.metrics.applyUpdate({ + relay: relayLabel, + openOk: false, + readOk: false, + writeConfirmOk: false, + up: false, + openDurationMs, + readDurationMs, + writeDurationMs, + ...(state.lastSuccessUnix !== undefined ? { lastSuccessUnix: state.lastSuccessUnix } : {}), + }); + this.logger.error( + { relay: relayLabel, error: messageOf(error), durationMs: Date.now() - probeStarted }, + "relay probe failed", + ); + } + } +} diff --git a/src/writeConfirm.ts b/src/writeConfirm.ts new file mode 100644 index 0000000..01fe8a0 --- /dev/null +++ b/src/writeConfirm.ts @@ -0,0 +1,142 @@ +import { performance } from "node:perf_hooks"; +import { finalizeEvent, getPublicKey, nip19, Relay, type Filter } from "nostr-tools"; + +interface WriteConfirmInput { + relay: string; + host: string; + kind: number; + privkey: string; + timeoutMs: number; +} + +export interface WriteConfirmResult { + ok: boolean; + durationMs: number; + reason?: string; +} + +function isHex64(value: string): boolean { + return /^[0-9a-f]{64}$/i.test(value); +} + +function decodePrivateKey(raw: string): Uint8Array { + const value = raw.trim(); + if (value.startsWith("nsec1")) { + const decoded = nip19.decode(value); + if (decoded.type !== "nsec") { + throw new Error("WRITE_CHECK_PRIVKEY is not a valid nsec key"); + } + return decoded.data; + } + if (!isHex64(value)) { + throw new Error("WRITE_CHECK_PRIVKEY must be nsec or 64-char hex"); + } + return Uint8Array.from(Buffer.from(value, "hex")); +} + +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch((error: unknown) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +async function waitForEvent( + relay: Relay, + filter: Filter, + expectedEventId: string, + timeoutMs: number, +): Promise { + return withTimeout( + new Promise((resolve) => { + let settled = false; + const sub = relay.subscribe([filter], { + onevent: (evt) => { + if (settled) return; + if (evt.id === expectedEventId) { + settled = true; + sub.close(); + resolve(true); + } + }, + oneose: () => { + if (settled) return; + settled = true; + sub.close(); + resolve(false); + }, + }); + }), + timeoutMs, + "write confirm read-after-write", + ); +} + +export async function runWriteConfirm(input: WriteConfirmInput): Promise { + const startedAt = performance.now(); + let relay: Relay | null = null; + try { + const secretKey = decodePrivateKey(input.privkey); + const derivedPubkey = getPublicKey(secretKey); + + relay = await withTimeout( + Relay.connect(input.relay, { enablePing: false, enableReconnect: false }), + input.timeoutMs, + "relay connect", + ); + + const createdAt = Math.floor(Date.now() / 1000); + const dTag = `healthcheck:${input.host}`; + + const event = finalizeEvent( + { + kind: input.kind, + created_at: createdAt, + tags: [["d", dTag]], + content: "ok", + }, + secretKey, + ); + + await withTimeout(relay.publish(event), input.timeoutMs, "relay publish"); + + const elapsedMs = performance.now() - startedAt; + const remainingMs = Math.max(1, input.timeoutMs - Math.floor(elapsedMs)); + + const confirmed = await waitForEvent( + relay, + { + authors: [derivedPubkey], + kinds: [input.kind], + "#d": [dTag], + limit: 1, + since: Math.max(0, createdAt - 5), + }, + event.id, + remainingMs, + ); + + return { + ok: confirmed, + durationMs: Math.round(performance.now() - startedAt), + ...(confirmed ? {} : { reason: "event not observed on read-after-write query" }), + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "unknown write confirmation error"; + return { + ok: false, + durationMs: Math.round(performance.now() - startedAt), + reason: message, + }; + } finally { + relay?.close(); + } +} diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..eb7ba26 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { loadConfig } from "../src/config.js"; + +test("loadConfig parses valid relays and ignores invalid entries", () => { + const config = loadConfig({ + RELAYS: "wss://relay.damus.io,not-a-url,wss://nos.lol", + WRITE_CHECK_ENABLED: "false", + }); + + assert.equal(config.relays.length, 2); + assert.equal(config.relays[0]?.relay, "wss://relay.damus.io/"); + assert.equal(config.relays[1]?.relay, "wss://nos.lol/"); + assert.equal(config.writeCheck.enabled, false); + assert.equal(config.probeIntervalMs, 0); + assert.ok(config.warnings.some((w) => w.includes("Ignoring invalid relay URL"))); +}); + +test("loadConfig generates write-check private key when missing", () => { + const config = loadConfig({ + RELAYS: "wss://relay.damus.io", + WRITE_CHECK_ENABLED: "true", + }); + + assert.equal(config.writeCheck.enabled, true); + assert.match(config.writeCheck.privkey ?? "", /^[0-9a-f]{64}$/i); + assert.ok( + config.warnings.some((w) => w.includes("generated ephemeral write-check key")), + "expected warning about generated write-check key", + ); +}); + +test("loadConfig generates write-check private key when configured key is invalid", () => { + const config = loadConfig({ + RELAYS: "wss://relay.damus.io", + WRITE_CHECK_ENABLED: "true", + WRITE_CHECK_PRIVKEY: "not-a-valid-key", + }); + + assert.equal(config.writeCheck.enabled, true); + assert.match(config.writeCheck.privkey ?? "", /^[0-9a-f]{64}$/i); + assert.notEqual(config.writeCheck.privkey, "not-a-valid-key"); + assert.ok( + config.warnings.some((w) => w.includes("WRITE_CHECK_PRIVKEY invalid")), + "expected warning about invalid configured key", + ); +}); diff --git a/test/smoke.test.ts b/test/smoke.test.ts new file mode 100644 index 0000000..c30aa7c --- /dev/null +++ b/test/smoke.test.ts @@ -0,0 +1,109 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import http from "node:http"; +import { setTimeout as sleep } from "node:timers/promises"; + +function randomPort(): number { + return 20_000 + Math.floor(Math.random() * 10_000); +} + +function httpGet( + port: number, + path: string, +): Promise<{ statusCode: number; body: string; headers: http.IncomingHttpHeaders }> { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: "127.0.0.1", + port, + path, + method: "GET", + timeout: 4_000, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + res.on("end", () => { + resolve({ + statusCode: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf8"), + headers: res.headers, + }); + }); + }, + ); + req.on("timeout", () => { + req.destroy(new Error(`timeout GET ${path}`)); + }); + req.on("error", reject); + req.end(); + }); +} + +async function waitForStartup(child: ChildProcessWithoutNullStreams, port: number, timeoutMs: number): Promise { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + if (child.exitCode !== null) { + throw new Error(`exporter exited early with code ${child.exitCode}`); + } + try { + const res = await httpGet(port, "/healthz"); + if ([200, 503].includes(res.statusCode)) return; + } catch { + // server not up yet + } + await sleep(125); + } + throw new Error("timeout waiting for exporter startup"); +} + +test("exporter serves healthz and metrics without crashing", async (t) => { + const port = randomPort(); + const child = spawn( + "pnpm", + ["exec", "tsx", "src/index.ts"], + { + cwd: process.cwd(), + env: { + ...process.env, + RELAYS: "wss://relay.damus.io,wss://nos.lol", + WRITE_CHECK_ENABLED: "false", + PROBE_TIMEOUT_MS: "2000", + PROBE_INTERVAL_MS: "3000", + PORT: String(port), + LOG_LEVEL: "error", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let stderr = ""; + child.stderr.on("data", (d: Buffer) => { + stderr += d.toString("utf8"); + }); + + t.after(() => { + if (child.exitCode === null) { + child.kill("SIGTERM"); + } + }); + + await waitForStartup(child, port, 8000); + + const health = await httpGet(port, "/healthz"); + assert.ok([200, 503].includes(health.statusCode), `unexpected /healthz status ${health.statusCode}`); + assert.match(health.body, /"status": "(ok|degraded)"/); + + const metrics = await httpGet(port, "/metrics"); + assert.equal(metrics.statusCode, 200); + assert.match(metrics.body, /nostr_relay_up/); + assert.match(metrics.body, /nostr_relay_open_ok/); + assert.match(metrics.body, /nostr_relay_probe_runs_total/); + assert.match(metrics.body, /process_cpu_user_seconds_total|nodejs_eventloop_lag_seconds/); + + await sleep(3500); + assert.equal(child.exitCode, null, `exporter crashed unexpectedly: ${stderr}`); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2d332c5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"], + "exclude": ["dist", "node_modules"] +}