relay-exporter/src/config.ts
2026-03-22 16:02:18 -07:00

220 lines
6.0 KiB
TypeScript

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 MS_PER_SECOND = 1_000;
const DEFAULT_PROBE_INTERVAL_SECONDS = 0;
const DEFAULT_PROBE_TIMEOUT_SECONDS = 10;
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_SECONDS = 1;
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 probeIntervalSeconds = parseInteger(
env.PROBE_INTERVAL_SECONDS,
DEFAULT_PROBE_INTERVAL_SECONDS,
0,
"PROBE_INTERVAL_SECONDS",
);
const probeTimeoutSeconds = parseInteger(
env.PROBE_TIMEOUT_SECONDS,
DEFAULT_PROBE_TIMEOUT_SECONDS,
MIN_TIMEOUT_SECONDS,
"PROBE_TIMEOUT_SECONDS",
);
const probeIntervalMs = probeIntervalSeconds * MS_PER_SECOND;
const probeTimeoutMs = probeTimeoutSeconds * MS_PER_SECOND;
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,
};
}