Files
tehran_tala/apps/api/src/modules/sms/drivers/relay.driver.ts
T
s.esmaeiliandClaude Opus 5.5 7f851676b5 fix(sms): inside Docker, send the server's public IP as the relay host, not the container's
Messages went out locally but not from the server. The relay gateway
identifies the sending server by `host`, and the owner's ruling (D26)
is that this is the server's IP. With SMS_RELAY_HOST unset, the driver
sent the machine's first external IPv4. On the bare PHP server that was
the real IP; inside the API container it is the container's (172.x).
Reproduced: a container on this machine reports 172.17.0.3.

- relay.driver: when SMS_RELAY_HOST is unset and the machine address is
  private (RFC 1918, CGNAT, link-local), the public IPv4 is looked up
  once (ipify, ifconfig.me, icanhazip in turn) and cached. If every
  lookup fails, it falls back to the old value. SMS_RELAY_HOST still wins.
- SmsService logs the host it will send at boot, and warns when it is
  still private, so a wrong value is visible instead of silent.
- Removed a debug console.log that wrote every recipient's mobile to the
  server log.
- .env.example and SERVER-SETUP-CHECKLIST: SMS_RELAY_HOST = this
  server's public IP.

Tests: relay-host.spec (5). All 254 API tests pass.

اپ: پیامک کد ورود دوباره به‌درستی ارسال می‌شود.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:58:13 +03:30

152 lines
5.5 KiB
TypeScript

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;
}
/** RFC 1918, carrier-grade NAT and link-local: addresses only a private network knows. */
export function isPrivateIpv4(ip: string | null): boolean {
return !!ip && /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.|169\.254\.)/.test(ip);
}
/**
* The server's public IPv4, looked up once. Only used when SMS_RELAY_HOST is
* unset and the machine's own address is private — inside Docker that address
* is the container's (172.x), which is not "the server" to the gateway. The
* PHP client ran on the bare server, where gethostbyname gave its real IP;
* this restores that value (owner ruling D26: host = the server's IP).
*/
let publicIpv4: string | null = null;
const PUBLIC_IP_SERVICES = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com'];
export async function resolveRelayHost(): Promise<string | null> {
if (envOrNull('SMS_RELAY_HOST') || publicIpv4) return relayConfig().host;
const own = serverIpv4();
if (!isPrivateIpv4(own)) return own;
for (const url of PUBLIC_IP_SERVICES) {
try {
const reply = await postWithTimeout(url, {}, null, 5_000, 'GET');
const ip = reply.body.trim();
if (reply.ok && /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) && !isPrivateIpv4(ip)) {
publicIpv4 = ip;
break;
}
} catch {
/* try the next service */
}
}
return relayConfig().host;
}
/** Tests only. */
export function resetRelayHostCache() {
publicIpv4 = 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') ?? publicIpv4 ?? serverIpv4(),
// "default" sends null, so the gateway chooses the line itself.
platform: platform.toLowerCase() === 'default' ? null : platform,
};
}
/**
* 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<string, unknown>;
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) {
await resolveRelayHost();
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, platform: c.platform }),
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 };
},
};