diff --git a/apps/api/.env.example b/apps/api/.env.example index d07c8ae..8c5f422 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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= diff --git a/apps/api/release/app.js b/apps/api/release/app.js index 8c006ea..a0e5531 100644 --- a/apps/api/release/app.js +++ b/apps/api/release/app.js @@ -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); diff --git a/apps/api/src/modules/sms/drivers/relay-host.spec.ts b/apps/api/src/modules/sms/drivers/relay-host.spec.ts new file mode 100644 index 0000000..215e8cd --- /dev/null +++ b/apps/api/src/modules/sms/drivers/relay-host.spec.ts @@ -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'); + }); +}); diff --git a/apps/api/src/modules/sms/drivers/relay.driver.ts b/apps/api/src/modules/sms/drivers/relay.driver.ts index 04e8116..2d806ac 100644 --- a/apps/api/src/modules/sms/drivers/relay.driver.ts +++ b/apps/api/src/modules/sms/drivers/relay.driver.ts @@ -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 { + 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, diff --git a/apps/api/src/modules/sms/drivers/sms-driver.ts b/apps/api/src/modules/sms/drivers/sms-driver.ts index fc53e9b..6e00235 100644 --- a/apps/api/src/modules/sms/drivers/sms-driver.ts +++ b/apps/api/src/modules/sms/drivers/sms-driver.ts @@ -35,17 +35,18 @@ export interface HttpReply { export async function postWithTimeout( url: string, headers: Record, - body: string, + body: string | null, timeoutMs: number, + method: 'POST' | 'GET' = 'POST', ): Promise { 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() }; diff --git a/apps/api/src/modules/sms/sms.service.ts b/apps/api/src/modules/sms/sms.service.ts index cd5bf21..a6da7eb 100644 --- a/apps/api/src/modules/sms/sms.service.ts +++ b/apps/api/src/modules/sms/sms.service.ts @@ -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) { diff --git a/docs/SERVER-SETUP-CHECKLIST.md b/docs/SERVER-SETUP-CHECKLIST.md index fe85b50..1b36b6c 100644 --- a/docs/SERVER-SETUP-CHECKLIST.md +++ b/docs/SERVER-SETUP-CHECKLIST.md @@ -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» دیدید، این متغیر را پر کنید. + --- ## بعد از تنظیم