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>
This commit is contained in:
s.esmaeili
2026-10-10 12:58:13 +03:30
co-authored by Claude Opus 5.5
parent c47d02d377
commit 7f851676b5
7 changed files with 201 additions and 14 deletions
+4 -1
View File
@@ -31,7 +31,10 @@ SMS_PROVIDER=relay
# relay — all optional; URL and secret have built-in defaults.
SMS_RELAY_URL=
SMS_RELAY_SECRET=
# The "host" field; blank sends this machine's first external IPv4.
# The "host" field: this server's public IP (owner ruling D26). Blank sends the
# machine's first external IPv4 — and when that is a private address (inside
# Docker it is the container's 172.x), the public IP is looked up once at boot.
# Setting it explicitly on the server is the surest; the boot log shows the value.
SMS_RELAY_HOST=
# "kave" (default, Kavenegar) or "default" to let the gateway pick the line.
SMS_RELAY_PLATFORM=
+53 -5
View File
@@ -87469,14 +87469,14 @@ var require_sms_driver = __commonJS({
"use strict";
Object.defineProperty(exports2, "__esModule", { value: true });
exports2.postWithTimeout = postWithTimeout;
async function postWithTimeout(url, headers, body, timeoutMs) {
async function postWithTimeout(url, headers, body, timeoutMs, method = "POST") {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: "POST",
method,
headers,
body,
body: body ?? void 0,
signal: controller.signal
});
return { ok: response.ok, status: response.status, body: await response.text() };
@@ -87494,6 +87494,9 @@ var require_relay_driver = __commonJS({
Object.defineProperty(exports2, "__esModule", { value: true });
exports2.relayDriver = void 0;
exports2.serverIpv4 = serverIpv4;
exports2.isPrivateIpv4 = isPrivateIpv4;
exports2.resolveRelayHost = resolveRelayHost;
exports2.resetRelayHostCache = resetRelayHostCache;
exports2.relayConfig = relayConfig;
exports2.relayRejection = relayRejection;
var node_os_1 = require("node:os");
@@ -87512,12 +87515,39 @@ var require_relay_driver = __commonJS({
}
return null;
}
function isPrivateIpv4(ip) {
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);
}
var publicIpv4 = null;
var PUBLIC_IP_SERVICES = ["https://api.ipify.org", "https://ifconfig.me/ip", "https://icanhazip.com"];
async function resolveRelayHost() {
if ((0, env_12.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 (0, sms_driver_1.postWithTimeout)(url, {}, null, 5e3, "GET");
const ip = reply.body.trim();
if (reply.ok && /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) && !isPrivateIpv4(ip)) {
publicIpv4 = ip;
break;
}
} catch {
}
}
return relayConfig().host;
}
function resetRelayHostCache() {
publicIpv4 = null;
}
function relayConfig() {
const platform = (0, env_12.envOr)("SMS_RELAY_PLATFORM", DEFAULT_RELAY_PLATFORM);
return {
url: (0, env_12.envOr)("SMS_RELAY_URL", DEFAULT_RELAY_URL),
secret: (0, env_12.envOr)("SMS_RELAY_SECRET", DEFAULT_RELAY_SECRET),
host: (0, env_12.envOrNull)("SMS_RELAY_HOST") ?? serverIpv4(),
host: (0, env_12.envOrNull)("SMS_RELAY_HOST") ?? publicIpv4 ?? serverIpv4(),
// "default" sends null, so the gateway chooses the line itself.
platform: platform.toLowerCase() === "default" ? null : platform
};
@@ -87551,6 +87581,7 @@ var require_relay_driver = __commonJS({
return relayConfig().host ? [] : ["SMS_RELAY_HOST"];
},
async deliver(mobile, text) {
await resolveRelayHost();
const c = relayConfig();
const reply = await (0, sms_driver_1.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)
@@ -87651,6 +87682,7 @@ var require_sms_service = __commonJS({
var prisma_service_1 = require_prisma_service();
var env_12 = require_env();
var drivers_1 = require_drivers();
var relay_driver_1 = require_relay_driver();
function smsProvider() {
return (0, env_12.envOr)("SMS_PROVIDER", drivers_1.DEFAULT_SMS_PROVIDER).toLowerCase();
}
@@ -87686,6 +87718,23 @@ var require_sms_service = __commonJS({
constructor(prisma) {
this.prisma = prisma;
}
/**
* Reports the `host` the relay gateway will receive, so a wrong one shows in
* the boot log instead of as silent failures. Never throws and never delays
* the boot: the lookup runs in the background.
*/
onApplicationBootstrap() {
if (smsDriver()?.name !== "relay")
return;
void (0, relay_driver_1.resolveRelayHost)().then((host) => {
const source = (0, env_12.envOrNull)("SMS_RELAY_HOST") ? "SMS_RELAY_HOST" : "detected";
if ((0, relay_driver_1.isPrivateIpv4)(host)) {
this.logger.warn(`SMS relay host is a private address (${host}, ${source}); the gateway may not accept it. Set SMS_RELAY_HOST to this server's public IP.`);
} else {
this.logger.log(`SMS relay host: ${host ?? "(none)"} (${source})`);
}
}).catch(() => void 0);
}
/**
* Sends one message. Never throws — a failed SMS must not roll back the
* business operation that triggered it, exactly as in the reference.
@@ -87693,7 +87742,6 @@ var require_sms_service = __commonJS({
async send(mobile, body, options = {}) {
const { persist = true, audience = client_1.MessageAudience.SINGLE_NUMBER } = options;
const text = body.trim();
console.log("mobile ==>", mobile);
const result = await this.deliver(mobile, text);
if (persist) {
await this.record(mobile, text, audience);
@@ -0,0 +1,60 @@
import { isPrivateIpv4, relayConfig, resetRelayHostCache, resolveRelayHost } from './relay.driver';
jest.mock('node:os', () => ({ networkInterfaces: jest.fn() }));
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { networkInterfaces } = require('node:os') as { networkInterfaces: jest.Mock };
const iface = (address: string) => ({ address, family: 'IPv4', internal: false });
/** Inside Docker the machine's own IPv4 is the container's; the gateway needs the server's. */
describe('relay host', () => {
const realFetch = global.fetch;
let fetchMock: jest.Mock;
beforeEach(() => {
resetRelayHostCache();
delete process.env.SMS_RELAY_HOST;
fetchMock = jest.fn();
global.fetch = fetchMock as never;
});
afterAll(() => {
global.fetch = realFetch;
});
it('knows private ranges', () => {
for (const ip of ['10.1.2.3', '172.17.0.2', '172.31.255.1', '192.168.1.20', '100.64.0.1', '169.254.1.1']) {
expect(isPrivateIpv4(ip)).toBe(true);
}
for (const ip of ['185.231.112.101', '172.15.0.1', '172.32.0.1', '8.8.8.8']) {
expect(isPrivateIpv4(ip)).toBe(false);
}
});
it('a container address is replaced by the public IP, looked up once', async () => {
networkInterfaces.mockReturnValue({ eth0: [iface('172.18.0.5')] });
fetchMock.mockResolvedValue({ ok: true, status: 200, text: async () => '81.12.34.56\n' });
expect(await resolveRelayHost()).toBe('81.12.34.56');
expect(relayConfig().host).toBe('81.12.34.56');
await resolveRelayHost();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('SMS_RELAY_HOST always wins, with no lookup', async () => {
process.env.SMS_RELAY_HOST = '5.6.7.8';
networkInterfaces.mockReturnValue({ eth0: [iface('172.18.0.5')] });
expect(await resolveRelayHost()).toBe('5.6.7.8');
expect(fetchMock).not.toHaveBeenCalled();
});
it('a public machine address is used as is, with no lookup', async () => {
networkInterfaces.mockReturnValue({ eth0: [iface('81.12.34.56')] });
expect(await resolveRelayHost()).toBe('81.12.34.56');
expect(fetchMock).not.toHaveBeenCalled();
});
it('when every lookup fails, falls back to the machine address', async () => {
networkInterfaces.mockReturnValue({ eth0: [iface('172.18.0.5')] });
fetchMock.mockRejectedValue(new Error('offline'));
expect(await resolveRelayHost()).toBe('172.18.0.5');
});
});
@@ -39,12 +39,51 @@ export function serverIpv4(): string | null {
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') ?? serverIpv4(),
host: envOrNull('SMS_RELAY_HOST') ?? publicIpv4 ?? serverIpv4(),
// "default" sends null, so the gateway chooses the line itself.
platform: platform.toLowerCase() === 'default' ? null : platform,
};
@@ -93,6 +132,7 @@ export const relayDriver: SmsDriver = {
},
async deliver(mobile, text) {
await resolveRelayHost();
const c = relayConfig();
const reply = await postWithTimeout(
c.url,
@@ -35,17 +35,18 @@ export interface HttpReply {
export async function postWithTimeout(
url: string,
headers: Record<string, string>,
body: string,
body: string | null,
timeoutMs: number,
method: 'POST' | 'GET' = 'POST',
): Promise<HttpReply> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: 'POST',
method,
headers,
body,
body: body ?? undefined,
signal: controller.signal,
});
return { ok: response.ok, status: response.status, body: await response.text() };
+26 -4
View File
@@ -1,8 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { MessageAudience } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { envOr } from '../../common/env';
import { envOr, envOrNull } from '../../common/env';
import { DEFAULT_SMS_PROVIDER, SMS_DRIVERS, SmsDriver, SmsSendResult } from './drivers';
import { isPrivateIpv4, resolveRelayHost } from './drivers/relay.driver';
/**
* SMS delivery — port of henza_server/app/Models/Sms.php, with the gateway
@@ -96,11 +97,33 @@ export function assertSmsConfigured(logger: Logger = new Logger('SmsConfig')): v
}
@Injectable()
export class SmsService {
export class SmsService implements OnApplicationBootstrap {
private readonly logger = new Logger(SmsService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Reports the `host` the relay gateway will receive, so a wrong one shows in
* the boot log instead of as silent failures. Never throws and never delays
* the boot: the lookup runs in the background.
*/
onApplicationBootstrap() {
if (smsDriver()?.name !== 'relay') return;
void resolveRelayHost()
.then((host) => {
const source = envOrNull('SMS_RELAY_HOST') ? 'SMS_RELAY_HOST' : 'detected';
if (isPrivateIpv4(host)) {
this.logger.warn(
`SMS relay host is a private address (${host}, ${source}); the gateway may not accept it. ` +
"Set SMS_RELAY_HOST to this server's public IP.",
);
} else {
this.logger.log(`SMS relay host: ${host ?? '(none)'} (${source})`);
}
})
.catch(() => undefined);
}
/**
* Sends one message. Never throws — a failed SMS must not roll back the
* business operation that triggered it, exactly as in the reference.
@@ -110,7 +133,6 @@ export class SmsService {
// The reference trims before sending (`Sms.php:77`).
const text = body.trim();
console.log("mobile ==>" ,mobile)
const result = await this.deliver(mobile, text);
if (persist) {
+13
View File
@@ -64,6 +64,19 @@ CORS_ORIGINS=https://adm.henzagold.ir,https://app.henzagold.ir,https://app2.henz
وگرنه آن صفحه نمی‌تواند با سرور حرف بزند.
- `*` پذیرفته نمی‌شود.
## ۴. پیامک: نشانی سرور
| متغیر | مقدار درست |
|---|---|
| `SMS_RELAY_HOST` | **نشانی IP عمومی همین سرور** |
سرویس پیامک، سرور فرستنده را با این مقدار می‌شناسد. اگر خالی باشد، برنامه خودش
نشانی عمومی سرور را پیدا می‌کند؛ ولی نوشتن آن مطمئن‌ترین راه است. داخل داکر،
نشانیِ خودِ برنامه یک نشانی داخلی (`172.x`) است و سرویس پیامک آن را نمی‌شناسد.
بعد از راه‌اندازی، در گزارش سرور خطی مثل `SMS relay host: …` نشانی‌ای را که
فرستاده می‌شود نشان می‌دهد. اگر هشدار «private address» دیدید، این متغیر را پر کنید.
---
## بعد از تنظیم