diff --git a/.env.example b/.env.example index efe46ad..6884e29 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,11 @@ SMS_GAMA_SOURCE_SERVICE=5000110005 # Partner gateway: domain:apiKey pairs, comma-separated (www. is stripped) # Example: SMS_PARTNERS=baloutpastry.com:replace-with-long-random-secret SMS_PARTNERS= +# Local-dev only: proxy SMS delivery through production partner gateway +# (OTP still stored in local Redis). Leave unset on production. +# SMS_PROXY_URL=https://api.meshkee.com/api/v1/public/sms/send +# SMS_PROXY_DOMAIN=meshkee.local +# SMS_PROXY_API_KEY= # Object storage (Parspack / S3-compatible) # Bucket id is the Parspack account id. Object keys live under meshkee/... diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 880bc22..f94041e 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -641,7 +641,13 @@ "schema": { "type": "object", "required": ["cellNumber"], - "properties": { "cellNumber": { "type": "string" } } + "properties": { + "cellNumber": { "type": "string" }, + "domain": { + "type": "string", + "description": "Tenant host/apex used to brand the OTP SMS with the business Farsi name" + } + } } } } diff --git a/scripts/websites-agent/README.md b/scripts/websites-agent/README.md index 38db041..fcce156 100644 --- a/scripts/websites-agent/README.md +++ b/scripts/websites-agent/README.md @@ -3,6 +3,7 @@ # Endpoints (X-Deploy-Token): # POST /deploy { slug } — git pull + build + pm2 restart # POST /provision { slug, host, gitRepoUrl } — clone + nginx + ecosystem + allowlist +# POST /ssl { host, slug? } — certbot for apex + www (nginx must exist) # GET /health # # Env (.env): PORT, DEPLOY_TOKEN, ALLOWED_SLUGS diff --git a/scripts/websites-agent/server.js b/scripts/websites-agent/server.js index aa87e3d..f2ad8ca 100644 --- a/scripts/websites-agent/server.js +++ b/scripts/websites-agent/server.js @@ -176,6 +176,42 @@ const server = http.createServer(async (req, res) => { } } + if (req.method === 'POST' && req.url === '/ssl') { + if (!assertAuth(req, res)) return; + + let body; + try { + body = await readJson(req); + } catch { + return send(res, 400, { error: 'invalid json' }); + } + + const host = String(body.host || '').trim().toLowerCase(); + const slug = String(body.slug || '').trim(); + + if (!host || !/^[a-z0-9.-]+$/.test(host)) { + return send(res, 400, { error: 'valid host is required' }); + } + + if (slug && !allowedSlugs().has(slug)) { + return send(res, 400, { error: 'unknown or disallowed slug' }); + } + + try { + const result = await runScript('/opt/websites-agent/ssl.sh', [host]); + return send(res, 200, { + status: 'issued', + host, + log: (result.stdout || '').slice(-2000), + }); + } catch (err) { + return send(res, 500, { + error: 'ssl issue failed', + detail: err instanceof Error ? err.message.slice(0, 2000) : String(err), + }); + } + } + return send(res, 404, { error: 'not found' }); }); diff --git a/scripts/websites-agent/ssl.sh b/scripts/websites-agent/ssl.sh new file mode 100644 index 0000000..d4fcdc8 --- /dev/null +++ b/scripts/websites-agent/ssl.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Issue / renew Let's Encrypt cert for a storefront apex (+ www) via nginx plugin. +set -euo pipefail + +HOST="${1:-}" + +if [[ -z "$HOST" ]]; then + echo "usage: ssl.sh " >&2 + exit 1 +fi + +if [[ ! "$HOST" =~ ^[a-z0-9.-]+$ ]]; then + echo "invalid host: $HOST" >&2 + exit 1 +fi + +NGINX_AVAILABLE="/etc/nginx/sites-available/$HOST" +NGINX_ENABLED="/etc/nginx/sites-enabled/$HOST" +LOG_DIR="/var/log/websites" +mkdir -p "$LOG_DIR" +LOG="$LOG_DIR/ssl-$HOST.log" +exec >>"$LOG" 2>&1 + +echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) ssl start: $HOST ====" + +if [[ ! -f "$NGINX_AVAILABLE" && ! -f "$NGINX_ENABLED" ]]; then + echo "missing nginx site for $HOST (run provision first)" + exit 1 +fi + +if ! command -v certbot >/dev/null 2>&1; then + echo "certbot not installed" + exit 1 +fi + +certbot --nginx -d "$HOST" -d "www.$HOST" \ + --non-interactive --agree-tos --register-unsafely-without-email --redirect + +echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) ssl ok: $HOST ====" diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index d9fef17..2971014 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -80,7 +80,7 @@ export class AuthController { @Post('send-otp') sendOtp(@Body() dto: SendOtpDto) { - return this.authService.sendOtp(dto.cellNumber); + return this.authService.sendOtp(dto.cellNumber, dto.domain); } @Post('verify-otp') diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index d57e857..5102bc9 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, + HttpException, Injectable, ServiceUnavailableException, UnauthorizedException, @@ -271,7 +272,7 @@ export class AuthService { return { message: 'Password changed successfully' }; } - async sendOtp(cellNumber: string) { + async sendOtp(cellNumber: string, domain?: string) { if (!this.sms.isEnabled()) { return { enabled: false, @@ -288,15 +289,28 @@ export class AuthService { throw new UnauthorizedException('Cell number is not registered'); } + let businessNameFa: string | undefined; + const host = domain?.trim(); + if (host) { + try { + const business = await this.tenant.resolveBusinessByDomain(host); + businessNameFa = + business.nameFa?.trim() || business.name?.trim() || undefined; + } catch { + // Domain may be unknown — still send OTP without branding suffix + } + } + const code = this.generateOtpCode(); await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS); try { - await this.sms.sendVerificationCode(cellNumber, code); - } catch { - throw new ServiceUnavailableException( - 'SMS provider is not configured yet', - ); + await this.sms.sendVerificationCode(cellNumber, code, businessNameFa); + } catch (err) { + if (err instanceof HttpException) { + throw err; + } + throw new ServiceUnavailableException('Unable to send SMS'); } return { diff --git a/src/auth/dto/send-otp.dto.ts b/src/auth/dto/send-otp.dto.ts index 1aadd15..04b2e11 100644 --- a/src/auth/dto/send-otp.dto.ts +++ b/src/auth/dto/send-otp.dto.ts @@ -1,4 +1,4 @@ -import { IsString, Matches } from 'class-validator'; +import { IsOptional, IsString, Matches, MaxLength } from 'class-validator'; export class SendOtpDto { @IsString() @@ -6,4 +6,10 @@ export class SendOtpDto { message: 'cellNumber must be in E.164 format (e.g. +989121234567)', }) cellNumber!: string; + + /** Tenant host/apex (e.g. sanihome.ir or business.sanihome.ir) — used to brand OTP SMS. */ + @IsOptional() + @IsString() + @MaxLength(253) + domain?: string; } diff --git a/src/auth/sms.service.ts b/src/auth/sms.service.ts index 4e16372..376f22a 100644 --- a/src/auth/sms.service.ts +++ b/src/auth/sms.service.ts @@ -27,7 +27,11 @@ export class SmsService { return this.config.get('SMS_ENABLED', 'false') === 'true'; } - async sendVerificationCode(cellNumber: string, code: string): Promise { + async sendVerificationCode( + cellNumber: string, + code: string, + businessNameFa?: string, + ): Promise { if (!this.isEnabled()) { this.logger.warn( `SMS disabled — verification code for ${cellNumber} not sent (code: ${code})`, @@ -35,7 +39,10 @@ export class SmsService { return { serverId: 'disabled' }; } - const message = `کد تایید شما: ${code}`; + const brand = businessNameFa?.trim(); + const message = brand + ? `کد تایید شما: ${code}\n${brand}` + : `کد تایید شما: ${code}`; return this.sendMessage(cellNumber, message); } @@ -58,9 +65,85 @@ export class SmsService { throw new BadRequestException('Invalid cellphone number'); } + if (this.isProxyConfigured()) { + return this.sendViaProxy(destination, text); + } + return this.sendQuick(destination, text); } + private isProxyConfigured(): boolean { + const url = this.config.get('SMS_PROXY_URL')?.trim(); + const apiKey = this.config.get('SMS_PROXY_API_KEY')?.trim(); + const domain = this.config.get('SMS_PROXY_DOMAIN')?.trim(); + return Boolean(url && apiKey && domain); + } + + /** Local-dev path: deliver via remote Meshkee partner SMS gateway (production reaches Gama). */ + private async sendViaProxy(destination: string, message: string): Promise { + const url = this.config.get('SMS_PROXY_URL')!.trim(); + const apiKey = this.config.get('SMS_PROXY_API_KEY')!.trim(); + const domain = this.config.get('SMS_PROXY_DOMAIN')!.trim(); + const masked = maskMsisdn(destination); + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'X-Api-Key': apiKey, + }, + body: JSON.stringify({ + domain, + to: destination, + message, + }), + signal: AbortSignal.timeout(20_000), + }); + } catch (err) { + this.logger.error( + `SMS proxy network error for ${masked}: ${err instanceof Error ? err.message : err}`, + ); + throw new ServiceUnavailableException('SMS provider is unreachable'); + } + + let payload: { success?: boolean; serverId?: string; message?: string } = {}; + try { + payload = (await response.json()) as typeof payload; + } catch { + this.logger.error(`SMS proxy returned non-JSON for ${masked} (HTTP ${response.status})`); + throw new ServiceUnavailableException('SMS provider returned an invalid response'); + } + + if (!response.ok || !payload.success) { + const providerMessage = + typeof payload.message === 'string' && payload.message.trim() + ? payload.message.trim() + : `HTTP ${response.status}`; + this.logger.warn(`SMS proxy failed for ${masked}: ${providerMessage}`); + if (response.status === 400) { + throw new BadRequestException(providerMessage || 'Invalid SMS destination or sender'); + } + if (response.status === 401) { + throw new ServiceUnavailableException('SMS provider authentication failed'); + } + throw new ServiceUnavailableException( + providerMessage || 'SMS provider rejected the request', + ); + } + + const serverId = String(payload.serverId ?? ''); + if (!serverId) { + this.logger.warn(`SMS proxy succeeded without serverId for ${masked}`); + throw new ServiceUnavailableException('SMS provider returned an empty server id'); + } + + this.logger.log(`SMS sent to ${masked} via proxy (serverId=${serverId})`); + return { serverId }; + } + private async sendQuick(destination: string, message: string): Promise { const username = this.config.get('SMS_GAMA_USERNAME')?.trim(); const password = this.config.get('SMS_GAMA_PASSWORD')?.trim(); diff --git a/src/domain-admin/domain-admin.controller.ts b/src/domain-admin/domain-admin.controller.ts index ccd422f..7aa7ab3 100644 --- a/src/domain-admin/domain-admin.controller.ts +++ b/src/domain-admin/domain-admin.controller.ts @@ -36,6 +36,14 @@ export class DomainAdminController { return this.service.syncSsl(user); } + /** Issue Let's Encrypt for storefront domains missing SSL (websites VM). */ + @Post('website-ssl') + @HttpCode(200) + @UseGuards(JwtAuthGuard) + issueWebsiteSsl(@CurrentUser() user: AuthUser) { + return this.service.issueWebsiteSsl(user); + } + @Post(':domainId/deploy') @HttpCode(202) @UseGuards(JwtAuthGuard) @@ -43,6 +51,13 @@ export class DomainAdminController { return this.service.deploy(domainId, user); } + @Post(':domainId/issue-ssl') + @HttpCode(200) + @UseGuards(JwtAuthGuard) + issueSsl(@Param('domainId') domainId: string, @CurrentUser() user: AuthUser) { + return this.service.issueSsl(domainId, user); + } + @Patch(':domainId') @UseGuards(JwtAuthGuard) update( diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts index 640b13e..c9424b5 100644 --- a/src/domain-admin/domain-admin.service.ts +++ b/src/domain-admin/domain-admin.service.ts @@ -10,6 +10,7 @@ import { ConfigService } from '@nestjs/config'; import { Prisma } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; +import { probeTlsHost } from '../common/tls-probe'; import { PrismaService } from '../prisma/prisma.service'; import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service'; import { DisableDomainDto } from './dto/disable-domain.dto'; @@ -125,7 +126,118 @@ export class DomainAdminService { return { status: 'accepted' as const, - message: 'SSL sync started on dashboards server', + message: 'Dashboard SSL sync started (manage / business.* / customer.*)', + }; + } + + /** + * Issue Let's Encrypt certs on the websites VM for storefront domains + * that have deploy_slug and currently report ssl_enabled=false. + */ + async issueWebsiteSsl(actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const targets = await this.prisma.domain.findMany({ + where: { + isActive: true, + sslEnabled: false, + deploySlug: { not: null }, + }, + select: { id: true, host: true, deploySlug: true }, + orderBy: { host: 'asc' }, + }); + + if (targets.length === 0) { + return { + status: 'ok' as const, + message: 'No storefront domains need SSL', + issued: [] as string[], + failed: [] as Array<{ host: string; error: string }>, + }; + } + + const issued: string[] = []; + const failed: Array<{ host: string; error: string }> = []; + + for (const domain of targets) { + try { + await this.websiteDeployAgent.issueSsl({ + host: domain.host, + slug: domain.deploySlug, + }); + const ok = await probeTlsHost(domain.host); + await this.prisma.domain.update({ + where: { id: domain.id }, + data: { sslEnabled: ok }, + }); + if (ok) { + issued.push(domain.host); + } else { + failed.push({ + host: domain.host, + error: 'Certbot finished but TLS probe still failed', + }); + } + } catch (err) { + failed.push({ + host: domain.host, + error: err instanceof Error ? err.message : 'SSL issue failed', + }); + } + } + + const message = + failed.length === 0 + ? `Issued SSL for ${issued.length} website(s)` + : `Issued ${issued.length}, failed ${failed.length} website SSL`; + + return { status: 'ok' as const, message, issued, failed }; + } + + async issueSsl(domainIdRaw: string, actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const domainId = BigInt(domainIdRaw); + const domain = await this.prisma.domain.findUnique({ where: { id: domainId } }); + if (!domain) { + throw new NotFoundException('Domain not found'); + } + + const slug = domain.deploySlug?.trim() || null; + if (!slug) { + throw new BadRequestException( + 'This domain has no storefront deploy configured — use Sync dashboard SSL for business/customer hosts', + ); + } + + try { + await this.websiteDeployAgent.issueSsl({ host: domain.host, slug }); + } catch (err) { + if (err instanceof ServiceUnavailableException) { + throw err; + } + throw new ServiceUnavailableException( + err instanceof Error ? err.message : 'SSL issue failed', + ); + } + + const ok = await probeTlsHost(domain.host); + const updated = await this.prisma.domain.update({ + where: { id: domainId }, + data: { sslEnabled: ok }, + }); + + if (!ok) { + throw new ServiceUnavailableException( + `Certbot ran for ${domain.host} but HTTPS probe failed — check DNS for apex and www`, + ); + } + + return { + status: 'issued' as const, + host: domain.host, + sslEnabled: updated.sslEnabled, + message: `SSL issued for ${domain.host}`, }; } diff --git a/src/website-deploy/website-deploy-agent.service.ts b/src/website-deploy/website-deploy-agent.service.ts index 8230fbf..f9dea59 100644 --- a/src/website-deploy/website-deploy-agent.service.ts +++ b/src/website-deploy/website-deploy-agent.service.ts @@ -1,6 +1,6 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { provisionUrlFromDeployUrl } from './website-deploy.util'; +import { provisionUrlFromDeployUrl, sslUrlFromDeployUrl } from './website-deploy.util'; @Injectable() export class WebsiteDeployAgentService { @@ -73,4 +73,43 @@ export class WebsiteDeployAgentService { return response.json().catch(() => ({ status: 'accepted', slug })); } + + /** Issue / renew Let's Encrypt cert for a storefront host on the websites VM. */ + async issueSsl(input: { host: string; slug?: string | null }) { + const { deployUrl, token } = this.credentials(); + const url = sslUrlFromDeployUrl(deployUrl); + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Deploy-Token': token, + }, + body: JSON.stringify({ + host: input.host, + ...(input.slug ? { slug: input.slug } : {}), + }), + }); + } catch { + throw new ServiceUnavailableException('Could not reach website deploy agent'); + } + + if (!response.ok) { + const text = await response.text().catch(() => ''); + let detail = text; + try { + const parsed = JSON.parse(text) as { detail?: string; error?: string }; + detail = parsed.detail || parsed.error || text; + } catch { + /* keep raw */ + } + throw new ServiceUnavailableException( + `SSL agent rejected request (${response.status})${detail ? `: ${detail}` : ''}`, + ); + } + + return response.json().catch(() => ({ status: 'issued', host: input.host })); + } } diff --git a/src/website-deploy/website-deploy.util.ts b/src/website-deploy/website-deploy.util.ts index 185998e..0d7e782 100644 --- a/src/website-deploy/website-deploy.util.ts +++ b/src/website-deploy/website-deploy.util.ts @@ -39,3 +39,12 @@ export function provisionUrlFromDeployUrl(deployUrl: string): string { } return `${trimmed}/provision`; } + +/** Turn .../deploy into .../ssl (or append /ssl if bare). */ +export function sslUrlFromDeployUrl(deployUrl: string): string { + const trimmed = deployUrl.trim().replace(/\/+$/, ''); + if (/\/deploy$/i.test(trimmed)) { + return trimmed.replace(/\/deploy$/i, '/ssl'); + } + return `${trimmed}/ssl`; +} diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 880bc22..f94041e 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -641,7 +641,13 @@ "schema": { "type": "object", "required": ["cellNumber"], - "properties": { "cellNumber": { "type": "string" } } + "properties": { + "cellNumber": { "type": "string" }, + "domain": { + "type": "string", + "description": "Tenant host/apex used to brand the OTP SMS with the business Farsi name" + } + } } } }