import { networkInterfaces } from 'node:os'; import { envOr, envOrNull } from '../../../common/env'; import { SmsDriver, postWithTimeout } from './sms-driver'; /** * The owner's SMS gateway (`/api/createsms`) — a relay that serves the SMS of * several panels and apps and forwards to the actual provider. The default * driver since D26. * * Shape, from the owner's PHP client: * * POST JSON, 15s timeout, auth header `secret_key` * * body: { phone, host, text, platform } * * `host` identifies the sending server: a configured value, else the * server's own IP (`gethostbyname(gethostname())`) * * `platform: "kave"` routes through Kavenegar; null lets the gateway pick. * The owner wants every message through Kavenegar. * * The secret is in source on the owner's decision (D26): the gateway has a * second verification factor held elsewhere. `SMS_RELAY_SECRET` overrides it. * * The PHP client forced IPv4 (CURLOPT_IPRESOLVE); the URL is an IP literal, so * there is nothing to resolve and fetch needs no equivalent. */ const DEFAULT_RELAY_URL = 'http://185.231.112.101/api/createsms'; const DEFAULT_RELAY_SECRET = 'f21debfb-83ac-4624-a2f4-f08375043181'; const DEFAULT_RELAY_PLATFORM = 'kave'; /** `Http::timeout(15)` in the owner's client. */ const REQUEST_TIMEOUT_MS = 15_000; /** The first external IPv4 of this machine — Node's `gethostbyname(gethostname())`. */ export function serverIpv4(): string | null { for (const addresses of Object.values(networkInterfaces())) { for (const a of addresses ?? []) { if (a.family === 'IPv4' && !a.internal) return a.address; } } return null; } export function relayConfig() { const platform = envOr('SMS_RELAY_PLATFORM', DEFAULT_RELAY_PLATFORM); return { url: envOr('SMS_RELAY_URL', DEFAULT_RELAY_URL), secret: envOr('SMS_RELAY_SECRET', DEFAULT_RELAY_SECRET), host: envOrNull('SMS_RELAY_HOST') ?? serverIpv4(), // "default" sends null, so the gateway chooses the line itself. }; } /** * The gateway's reply format is not documented, so a 2xx is trusted unless the * body plainly says otherwise. Returns the reason, or null when the reply * reads as accepted. * * A non-empty body that is not JSON counts as a failure: the gateway is a JSON * API, and its own PHP errors arrive as bare text with HTTP 200 — seen live on * 2026-10-05 as "cURL library is not loaded", with nothing delivered. */ export function relayRejection(body: string): string | null { const text = body.trim(); if (text === '') return null; let data: unknown; try { data = JSON.parse(text); } catch { return text.slice(0, 200); } if (!data || typeof data !== 'object' || Array.isArray(data)) return null; const d = data as Record; const failedStatus = d.status === false || (typeof d.status === 'string' && /^(error|fail|failed|failure)$/i.test(d.status)); const failed = failedStatus || d.success === false || d.ok === false || Boolean(d.error); if (!failed) return null; if (typeof d.error === 'string' && d.error) return d.error; if (typeof d.message === 'string' && d.message) return d.message; return 'rejected-by-gateway'; } export const relayDriver: SmsDriver = { name: 'relay', missingConfig() { // The URL and secret always have defaults; only the host can be missing, // on a machine with no external IPv4. return relayConfig().host ? [] : ['SMS_RELAY_HOST']; }, async deliver(mobile, text) { const c = relayConfig(); const reply = await postWithTimeout( c.url, { 'Content-Type': 'application/json', secret_key: c.secret }, JSON.stringify({ phone: mobile, host: c.host, text }), REQUEST_TIMEOUT_MS, ); if (!reply.ok) return { ok: false, response: reply.body, error: `http-${reply.status}` }; const rejection = relayRejection(reply.body); if (rejection) return { ok: false, response: reply.body, error: rejection }; return { ok: true, response: reply.body }; }, };