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>
152 lines
5.5 KiB
TypeScript
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 };
|
|
},
|
|
};
|