Implement exporter using nocap from nostr-watch along with tests
This commit is contained in:
parent
9c6aef1d92
commit
0531a23680
17
.env.example
Normal file
17
.env.example
Normal file
@ -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
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -7,6 +7,7 @@ node_modules
|
|||||||
.wrangler
|
.wrangler
|
||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
/build
|
/build
|
||||||
|
/dist
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@ -25,3 +26,4 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
.env
|
.env
|
||||||
archive
|
archive
|
||||||
|
result
|
||||||
135
README.md
Normal file
135
README.md
Normal file
@ -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`.
|
||||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@ -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
|
||||||
|
}
|
||||||
174
flake.nix
Normal file
174
flake.nix
Normal file
@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
22
package.json
22
package.json
@ -1,17 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "relay-exporter",
|
"name": "relay-exporter",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "Prometheus exporter for Nostr relay health checks",
|
||||||
"main": "index.js",
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"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": [],
|
"keywords": [
|
||||||
"author": "",
|
"nostr",
|
||||||
"license": "ISC",
|
"prometheus",
|
||||||
|
"exporter"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
"packageManager": "pnpm@10.28.0",
|
"packageManager": "pnpm@10.28.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nostrwatch/nocap": "^0.9.2",
|
"@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"
|
"prom-client": "^15.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
84
pnpm-lock.yaml
generated
84
pnpm-lock.yaml
generated
@ -11,6 +11,15 @@ importers:
|
|||||||
'@nostrwatch/nocap':
|
'@nostrwatch/nocap':
|
||||||
specifier: ^0.9.2
|
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)
|
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:
|
prom-client:
|
||||||
specifier: ^15.1.3
|
specifier: ^15.1.3
|
||||||
version: 15.1.3
|
version: 15.1.3
|
||||||
@ -570,6 +579,18 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
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':
|
'@nostrwatch/logger@0.0.9':
|
||||||
resolution: {integrity: sha512-G/Z4E9/vtJhP7bRu0WA8bXE+HiFTLO8bVWCT34RZnW7/W6fWOxoAbTt5fhqrLwzYVtVWcfHwF2fiDidaVhqsQA==}
|
resolution: {integrity: sha512-G/Z4E9/vtJhP7bRu0WA8bXE+HiFTLO8bVWCT34RZnW7/W6fWOxoAbTt5fhqrLwzYVtVWcfHwF2fiDidaVhqsQA==}
|
||||||
|
|
||||||
@ -603,6 +624,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
|
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
|
||||||
engines: {node: '>=8.0.0'}
|
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':
|
'@sinclair/typebox@0.27.10':
|
||||||
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
|
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
|
||||||
|
|
||||||
@ -1401,6 +1431,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
npm-run-path@4.0.1:
|
||||||
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -1428,6 +1469,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==}
|
resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
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:
|
p-locate@4.1.0:
|
||||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -2465,6 +2510,14 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@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':
|
'@nostrwatch/logger@0.0.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
logging: 3.3.0
|
logging: 3.3.0
|
||||||
@ -2552,6 +2605,19 @@ snapshots:
|
|||||||
|
|
||||||
'@opentelemetry/api@1.9.0': {}
|
'@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': {}
|
'@sinclair/typebox@0.27.10': {}
|
||||||
|
|
||||||
'@sinonjs/commons@3.0.1':
|
'@sinonjs/commons@3.0.1':
|
||||||
@ -3584,6 +3650,20 @@ snapshots:
|
|||||||
|
|
||||||
normalize-path@3.0.0: {}
|
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:
|
npm-run-path@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@ -3610,6 +3690,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
yocto-queue: 1.2.2
|
yocto-queue: 1.2.2
|
||||||
|
|
||||||
|
p-limit@7.3.0:
|
||||||
|
dependencies:
|
||||||
|
yocto-queue: 1.2.2
|
||||||
|
|
||||||
p-locate@4.1.0:
|
p-locate@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 2.3.0
|
p-limit: 2.3.0
|
||||||
|
|||||||
216
src/config.ts
Normal file
216
src/config.ts
Normal file
@ -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<string>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
147
src/index.ts
Normal file
147
src/index.ts
Normal file
@ -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<LogLevel, number> = {
|
||||||
|
debug: 10,
|
||||||
|
info: 20,
|
||||||
|
warn: 30,
|
||||||
|
error: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
function sanitizeFields(fields: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const sanitized: Record<string, unknown> = {};
|
||||||
|
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<string, unknown>, 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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
130
src/metrics.ts
Normal file
130
src/metrics.ts
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/nocap-adapters.d.ts
vendored
Normal file
4
src/nocap-adapters.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
declare module "@nostrwatch/nocap-every-adapter-default" {
|
||||||
|
const adapters: Record<string, new (...args: unknown[]) => unknown>;
|
||||||
|
export default adapters;
|
||||||
|
}
|
||||||
7
src/nocap.d.ts
vendored
Normal file
7
src/nocap.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
declare module "@nostrwatch/nocap" {
|
||||||
|
export class Nocap {
|
||||||
|
constructor(url: string, config?: unknown);
|
||||||
|
check(keys: string[], headers?: boolean): Promise<unknown>;
|
||||||
|
useAdapters(adapters: unknown[]): Promise<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
296
src/prober.ts
Normal file
296
src/prober.ts
Normal file
@ -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<string, unknown>, message: string): void;
|
||||||
|
info(fields: Record<string, unknown>, message: string): void;
|
||||||
|
warn(fields: Record<string, unknown>, message: string): void;
|
||||||
|
error(fields: Record<string, unknown>, 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<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||||
|
return new Promise<T>((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<string, RelayProbeState>();
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<NocapResult>,
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/writeConfirm.ts
Normal file
142
src/writeConfirm.ts
Normal file
@ -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<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||||
|
return new Promise<T>((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<boolean> {
|
||||||
|
return withTimeout<boolean>(
|
||||||
|
new Promise<boolean>((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<WriteConfirmResult> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
48
test/config.test.ts
Normal file
48
test/config.test.ts
Normal file
@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
109
test/smoke.test.ts
Normal file
109
test/smoke.test.ts
Normal file
@ -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<void> {
|
||||||
|
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}`);
|
||||||
|
});
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user