fix(platform): close remaining audit findings from security review
Harden preview/deploy flows, OTP generation, zip extraction, and multi-replica billing races; document full remediation status in AUDIT-STATUS.fa.md. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
|||||||
|
# وضعیت رفع یافتههای گزارش ممیزی CloudHost
|
||||||
|
|
||||||
|
> مرجع: `AUDIT-REPORT.fa.pdf` / `scripts/audit-report.fa.html`
|
||||||
|
> آخرین بهروزرسانی: ۳ تیر ۱۴۰۴ (3 Jul 2026)
|
||||||
|
|
||||||
|
| نماد | معنی |
|
||||||
|
|------|------|
|
||||||
|
| ✅ | رفع شده |
|
||||||
|
| ⚠️ | جزئی / نیاز به پیکربندی محیط |
|
||||||
|
| 🔜 | عمداً به تعویق افتاده (اسکوپ بزرگ یا trade-off) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## خلاصه
|
||||||
|
|
||||||
|
| دسته | تعداد | ✅ | ⚠️ | 🔜 |
|
||||||
|
|------|-------|----|----|-----|
|
||||||
|
| بلاکرهای پروداکشن | 8 | 8 | 0 | 0 |
|
||||||
|
| بیلد و Kaniko | 10 | 8 | 1 | 1 |
|
||||||
|
| دیپلوی و پیشنمایش | 6 | 6 | 0 | 0 |
|
||||||
|
| دیتابیس / سرویس اختیاری | 9 | 8 | 0 | 1 |
|
||||||
|
| Migration / اسکیما | 4 | 4 | 0 | 0 |
|
||||||
|
| بیلینگ و امنیت مالی | 5 | 5 | 0 | 0 |
|
||||||
|
| GitOps / CI-CD | 4 | 4 | 0 | 0 |
|
||||||
|
| امنیت اپ / auth | 6 | 3 | 0 | 3 |
|
||||||
|
| بهبود / زیرساخت | 5 | 2 | 1 | 2 |
|
||||||
|
|
||||||
|
**نتیجه:** همه بلاکرهای پروداکشن و تقریباً همه باگهای قطعی رفع شدهاند. موارد باقیمانده عمدتاً پیکربندی آینه رجیستری، JWT در localStorage، و پاکسازی PVC یتیم هستند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. بلاکرهای پروداکشن (اولویت ۱–۸)
|
||||||
|
|
||||||
|
| # | یافته | وضعیت | اقدام |
|
||||||
|
|---|--------|--------|-------|
|
||||||
|
| 1 | شارژ رایگان کیف پول (`POST /billing/wallet/charge`) | ✅ | HMAC + گارد production در `billing-wallet.controller.ts` |
|
||||||
|
| 2 | دیپلوی بدون پرداخت | ✅ | گارد بیلینگ در `triggerDeployment`, `startDeployment`, `PATCH resources` |
|
||||||
|
| 3 | namespace از ۸ کاراکتر UUID | ✅ | `userIdSlug` / `userNamespace` با UUID کامل |
|
||||||
|
| 4 | migration بدون ردیابی نسخه | ✅ | `schema_migrations` + `000_base_schema.sql` + pre-upgrade hook |
|
||||||
|
| 5 | `015` ستون `user_id` / `001` بدون گارد TYPE | ✅ | اصلاح نام ستون + `IF NOT EXISTS` |
|
||||||
|
| 6 | workflow Gitea بدون تست | ✅ | job تست + rebase در `.gitea/workflows/build-deploy.yaml` |
|
||||||
|
| 7 | رمز هاردکد Elasticsearch | ✅ | حذف از git + SealedSecret |
|
||||||
|
| 8 | COPY گو / `\|\| echo` Node | ✅ | اصلاح در `build.service.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. بیلد (Kaniko + Dockerfile)
|
||||||
|
|
||||||
|
| یافته | وضعیت | یادداشت |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| Go COPY نامعتبر | ✅ | |
|
||||||
|
| Node build failure نادیده | ✅ | `npm ci` + fail-on-build |
|
||||||
|
| Laravel extensions | ✅ | mbstring, xml, bcmath, zip, fileinfo, tokenizer |
|
||||||
|
| Python pyproject.toml | ✅ | تشخیص + نصب poetry/pdm |
|
||||||
|
| Kaniko 4Gi / PVC بدون SC | ✅ | limits قابل تنظیم + `BUILD_PVC_STORAGE_CLASS` |
|
||||||
|
| Git token در spec / branch injection / SSRF | ✅ | GIT_ASKPASS + Secret + validation |
|
||||||
|
| Zip slip در unzip | ✅ | اعتبارسنجی مسیر قبل و بعد از extract |
|
||||||
|
| state بیلد در حافظه | ✅ | Redis session + startup recovery |
|
||||||
|
| دیپلوی همزمان بدون قفل | ✅ | in-flight guard در `triggerDeployment` |
|
||||||
|
| Base image بدون آینه | ⚠️ | `BASE_IMAGE_REGISTRY` اضافه شده؛ باید در env پروداکشن ست شود |
|
||||||
|
| zip bomb (۱۰GiB) | 🔜 | سقف آپلود موجود؛ محدودیت تعداد entry در archive پیشنهاد میشود |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۳. دیپلوی و پیشنمایش
|
||||||
|
|
||||||
|
| یافته | وضعیت | یادداشت |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| حذف preview با custom domain pending | ✅ | `hasVerifiedCustomDomain()` در k8s + deployments |
|
||||||
|
| getPreviewInfo پچ NodePort | ✅ | فقط خواندن؛ ingressUrl اولویت دارد |
|
||||||
|
| fallback بینکلاستری → ImagePullBackOff | ✅ | `CLUSTER_DEPLOY_FALLBACK_ENABLED=true` برای fallback |
|
||||||
|
| NodePort host از API server | ⚠️ | `getClusterHostIp` همچنان fallback؛ ingressUrl مسیر اصلی |
|
||||||
|
| suspend NodePort revoke | ✅ | `deleteTemporaryAccessServicesForApp` در suspend |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۴. دیتابیس و سرویسهای اختیاری
|
||||||
|
|
||||||
|
| یافته | وضعیت | یادداشت |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| Redis/RabbitMQ randAlphaNum | ✅ | الگوی `lookup` در helm templates |
|
||||||
|
| probe بدون auth | ✅ | redis-cli `-a` / mongo با credential |
|
||||||
|
| RWO بدون Recreate | ✅ | `strategy: Recreate` |
|
||||||
|
| dbPassword fallback هر deploy | ✅ | generate + persist در DB |
|
||||||
|
| Mongo snapshot/restore | ✅ | |
|
||||||
|
| WordPress MySQL اجباری | ✅ | |
|
||||||
|
| wp-content restore از PVC | ✅ | |
|
||||||
|
| PVC یتیم بعد از suspend/delete | 🔜 | نیاز به job پاکسازی دورهای |
|
||||||
|
| ایمیج DB از Docker Hub | ⚠️ | آینه در `values.yaml`؛ پیکربندی per-cluster |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۵. Migration / اسکیما
|
||||||
|
|
||||||
|
| یافته | وضعیت |
|
||||||
|
|--------|--------|
|
||||||
|
| Job دوباره همه SQL | ✅ |
|
||||||
|
| post-upgrade → pre-upgrade | ✅ |
|
||||||
|
| نبود base schema | ✅ |
|
||||||
|
| 015 user_id | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۶. بیلینگ
|
||||||
|
|
||||||
|
| یافته | وضعیت |
|
||||||
|
|--------|--------|
|
||||||
|
| wallet charge بدون درگاه | ✅ |
|
||||||
|
| proration اشتباه | ✅ |
|
||||||
|
| race در wallet | ✅ | pessimistic lock |
|
||||||
|
| auto-renew دو بار بین replicas | ✅ | lock روی Application در transaction |
|
||||||
|
| دیپلوی بدون پرداخت | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۷. GitOps / CI-CD
|
||||||
|
|
||||||
|
| یافته | وضعیت |
|
||||||
|
|--------|--------|
|
||||||
|
| workflow بدون تست | ✅ |
|
||||||
|
| elastic password در git | ✅ |
|
||||||
|
| platform Redis requirepass | ✅ |
|
||||||
|
| backend RollingUpdate + limits + postgres backup | ✅ |
|
||||||
|
| Swagger در production | ✅ |
|
||||||
|
| RUNBOOK-DEPLOY portable | ✅ | commit `6d9cd89` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۸. امنیت اپلیکیشن
|
||||||
|
|
||||||
|
| یافته | وضعیت | یادداشت |
|
||||||
|
|--------|--------|---------|
|
||||||
|
| gitToken / dbPassword در API | ✅ | `@Exclude` + `hasDbPassword` / `hasGitToken` |
|
||||||
|
| Elasticsearch log isolation | ✅ | namespace کامل |
|
||||||
|
| OTP Math.random | ✅ | `crypto.randomInt` |
|
||||||
|
| OTP consume race | ✅ | pessimistic lock در transaction |
|
||||||
|
| JWT در localStorage | 🔜 | نیاز به httpOnly cookie + CSRF — اسکوپ frontend بزرگ |
|
||||||
|
| refresh token rotation | 🔜 | |
|
||||||
|
| secret پیشفرض dev | ⚠️ | `validate-production-config` در production fail میکند |
|
||||||
|
| docker compose NODE_ENV=production | ✅ | `NODE_ENV: development` برای dev محلی |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۹. بهبود / زیرساخت
|
||||||
|
|
||||||
|
| یافته | وضعیت |
|
||||||
|
|--------|--------|
|
||||||
|
| Backend Dockerfile helm/kubectl از اینترنت | 🔜 | mirror یا COPY از stage |
|
||||||
|
| orphan PVC cleanup | 🔜 |
|
||||||
|
| zip bomb hard limit | 🔜 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## متغیرهای محیطی جدید (مرجع deploy)
|
||||||
|
|
||||||
|
| متغیر | پیشفرض | توضیح |
|
||||||
|
|--------|---------|-------|
|
||||||
|
| `CLUSTER_DEPLOY_FALLBACK_ENABLED` | `false` | fallback بین کلاستر |
|
||||||
|
| `CLUSTER_DEPLOY_FALLBACK_ATTEMPTS` | `3` | فقط وقتی fallback فعال |
|
||||||
|
| `BASE_IMAGE_REGISTRY` | — | آینه base imageهای بیلد |
|
||||||
|
| `BUILD_PVC_STORAGE_CLASS` | — | StorageClass برای PVC بیلد |
|
||||||
|
| `BILLING_WALLET_HMAC_SECRET` | — | اجباری در production |
|
||||||
|
| `PAYMENT_GATEWAY_*` | — | اجباری برای charge واقعی |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## commitهای مرتبط
|
||||||
|
|
||||||
|
- `22359be` — fix(platform): apply production hardening from audit plan
|
||||||
|
- `6d9cd89` — docs: add portable from-zero deploy runbook and GitOps templates
|
||||||
|
- *(uncommitted)* — رفع موارد باقیمانده این سند (preview، OTP، zip slip، auto-renew lock، docker-compose)
|
||||||
@@ -52,9 +52,16 @@ export class Application {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
dbUsername: string;
|
dbUsername: string;
|
||||||
|
|
||||||
|
/** Never expose raw DB password in API responses — use hasDbPassword for UI. */
|
||||||
|
@Exclude({ toPlainOnly: true })
|
||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
dbPassword: string;
|
dbPassword: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
get hasDbPassword(): boolean {
|
||||||
|
return !!this.dbPassword;
|
||||||
|
}
|
||||||
|
|
||||||
@Column({ nullable: true, default: '1Gi' })
|
@Column({ nullable: true, default: '1Gi' })
|
||||||
dbStorageSize: string; // PVC storage size for database (e.g. '1Gi', '5Gi', '10Gi')
|
dbStorageSize: string; // PVC storage size for database (e.g. '1Gi', '5Gi', '10Gi')
|
||||||
|
|
||||||
|
|||||||
@@ -555,18 +555,26 @@ export class BuildService {
|
|||||||
'-c',
|
'-c',
|
||||||
`
|
`
|
||||||
apk add --no-cache unzip tar gzip &&
|
apk add --no-cache unzip tar gzip &&
|
||||||
|
reject_unsafe_path() {
|
||||||
|
case "$1" in ..|../*|*/../*|/*) echo "ERROR: unsafe archive path: $1" && exit 1;; esac
|
||||||
|
} &&
|
||||||
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
|
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
|
||||||
mkdir -p /tmp/extract &&
|
mkdir -p /tmp/extract &&
|
||||||
cd /tmp/extract &&
|
cd /tmp/extract &&
|
||||||
if tar tzf /source-pvc/source.zip >/dev/null 2>&1; then
|
if tar tzf /source-pvc/source.zip >/dev/null 2>&1; then
|
||||||
echo ">>> Detected gzip tarball" &&
|
echo ">>> Detected gzip tarball" &&
|
||||||
|
tar tzf /source-pvc/source.zip | while read -r entry; do reject_unsafe_path "$entry"; done &&
|
||||||
tar xzf /source-pvc/source.zip
|
tar xzf /source-pvc/source.zip
|
||||||
elif unzip -t /source-pvc/source.zip >/dev/null 2>&1; then
|
elif unzip -t /source-pvc/source.zip >/dev/null 2>&1; then
|
||||||
echo ">>> Detected zip archive" &&
|
echo ">>> Detected zip archive" &&
|
||||||
|
unzip -Z1 /source-pvc/source.zip | while read -r entry; do reject_unsafe_path "$entry"; done &&
|
||||||
unzip -q /source-pvc/source.zip
|
unzip -q /source-pvc/source.zip
|
||||||
else
|
else
|
||||||
echo "ERROR: source archive is not a valid zip or tar.gz" && exit 1
|
echo "ERROR: source archive is not a valid zip or tar.gz" && exit 1
|
||||||
fi &&
|
fi &&
|
||||||
|
find /tmp/extract -mindepth 1 -print | while read -r path; do
|
||||||
|
case "$path" in /tmp/extract|/tmp/extract/*) ;; *) echo "ERROR: zip slip detected: $path" && exit 1;; esac
|
||||||
|
done &&
|
||||||
echo "--- Extracted contents ---" &&
|
echo "--- Extracted contents ---" &&
|
||||||
ls -la /tmp/extract/ &&
|
ls -la /tmp/extract/ &&
|
||||||
mkdir -p /workspace-out/source &&
|
mkdir -p /workspace-out/source &&
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { BuildService, BuildProgress, BuildCancelledError } from '../build/build
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import {
|
import {
|
||||||
AppLifecycleStatus,
|
AppLifecycleStatus,
|
||||||
|
CustomDomainStatus,
|
||||||
DeploymentStatus,
|
DeploymentStatus,
|
||||||
isManagedProductType,
|
isManagedProductType,
|
||||||
MANAGED_DEPLOY_MARKER,
|
MANAGED_DEPLOY_MARKER,
|
||||||
@@ -100,6 +101,16 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
|
|
||||||
this.ensureAppPaidAndActive(app, 'deploying');
|
this.ensureAppPaidAndActive(app, 'deploying');
|
||||||
|
|
||||||
|
const inFlight = await this.deploymentsRepository.findOne({
|
||||||
|
where: {
|
||||||
|
applicationId: app.id,
|
||||||
|
status: In([DeploymentStatus.PENDING, DeploymentStatus.BUILDING, DeploymentStatus.DEPLOYING]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (inFlight) {
|
||||||
|
throw new BadRequestException('A deployment is already in progress for this application');
|
||||||
|
}
|
||||||
|
|
||||||
// Create deployment record
|
// Create deployment record
|
||||||
const deployment = this.deploymentsRepository.create({
|
const deployment = this.deploymentsRepository.create({
|
||||||
applicationId: app.id,
|
applicationId: app.id,
|
||||||
@@ -113,7 +124,7 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
|
|
||||||
// Fill deterministic preview number after we have the deployment id.
|
// Fill deterministic preview number after we have the deployment id.
|
||||||
let previewSubdomain: string | null = null;
|
let previewSubdomain: string | null = null;
|
||||||
if (!app.customDomain) {
|
if (!this.hasVerifiedCustomDomain(app)) {
|
||||||
previewSubdomain = await this.resolvePreviewNumber(app.id);
|
previewSubdomain = await this.resolvePreviewNumber(app.id);
|
||||||
await this.deploymentsRepository.update(saved.id, { previewSubdomain });
|
await this.deploymentsRepository.update(saved.id, { previewSubdomain });
|
||||||
saved.previewSubdomain = previewSubdomain;
|
saved.previewSubdomain = previewSubdomain;
|
||||||
@@ -339,7 +350,9 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
const failedClusterIds: string[] = [];
|
const failedClusterIds: string[] = [];
|
||||||
let currentApp = app;
|
let currentApp = app;
|
||||||
let lastError: any;
|
let lastError: any;
|
||||||
const maxAttempts = Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3);
|
const maxAttempts = process.env.CLUSTER_DEPLOY_FALLBACK_ENABLED === 'true'
|
||||||
|
? Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3)
|
||||||
|
: 1;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
if (await this.isDeploymentCancelled(deploymentId)) {
|
if (await this.isDeploymentCancelled(deploymentId)) {
|
||||||
@@ -409,7 +422,9 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
const failedClusterIds: string[] = [];
|
const failedClusterIds: string[] = [];
|
||||||
let currentApp = app;
|
let currentApp = app;
|
||||||
let lastError: any;
|
let lastError: any;
|
||||||
const maxAttempts = Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3);
|
const maxAttempts = process.env.CLUSTER_DEPLOY_FALLBACK_ENABLED === 'true'
|
||||||
|
? Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3)
|
||||||
|
: 1;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
if (await this.isDeploymentCancelled(deploymentId)) {
|
if (await this.isDeploymentCancelled(deploymentId)) {
|
||||||
@@ -735,7 +750,7 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
const saved = await this.deploymentsRepository.save(deployment);
|
const saved = await this.deploymentsRepository.save(deployment);
|
||||||
|
|
||||||
let previewSubdomain: string | null = null;
|
let previewSubdomain: string | null = null;
|
||||||
if (!app.customDomain) {
|
if (!this.hasVerifiedCustomDomain(app)) {
|
||||||
previewSubdomain = await this.resolvePreviewNumber(app.id);
|
previewSubdomain = await this.resolvePreviewNumber(app.id);
|
||||||
await this.deploymentsRepository.update(saved.id, { previewSubdomain });
|
await this.deploymentsRepository.update(saved.id, { previewSubdomain });
|
||||||
saved.previewSubdomain = previewSubdomain;
|
saved.previewSubdomain = previewSubdomain;
|
||||||
@@ -753,4 +768,9 @@ export class DeploymentsService implements OnModuleInit {
|
|||||||
async deleteAllForApplication(applicationId: string): Promise<void> {
|
async deleteAllForApplication(applicationId: string): Promise<void> {
|
||||||
await this.deploymentsRepository.delete({ applicationId });
|
await this.deploymentsRepository.delete({ applicationId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Preview stays available until the custom domain is verified (not merely requested). */
|
||||||
|
private hasVerifiedCustomDomain(app: { customDomain?: string | null; customDomainStatus?: CustomDomainStatus | null }): boolean {
|
||||||
|
return !!(app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,7 +313,9 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
const domain = this.configService.get('platform.domain');
|
const domain = this.configService.get('platform.domain');
|
||||||
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain;
|
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain;
|
||||||
const namespacePrefix = userIdSlug(app.userId);
|
const namespacePrefix = userIdSlug(app.userId);
|
||||||
const previewHost = previewNumber && !app.customDomain ? `${namespacePrefix}-${previewNumber}.${previewRootDomain}` : '';
|
const previewHost = previewNumber && !this.hasVerifiedCustomDomain(app)
|
||||||
|
? `${namespacePrefix}-${previewNumber}.${previewRootDomain}`
|
||||||
|
: '';
|
||||||
const pullRegistryUrl = this.registryService.getRegistryUrl();
|
const pullRegistryUrl = this.registryService.getRegistryUrl();
|
||||||
const isWordPress = app.runtime === AppRuntime.WORDPRESS;
|
const isWordPress = app.runtime === AppRuntime.WORDPRESS;
|
||||||
const hasDb = app.databaseType !== DatabaseType.NONE;
|
const hasDb = app.databaseType !== DatabaseType.NONE;
|
||||||
@@ -2347,6 +2349,8 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.deleteTemporaryAccessServicesForApp(app);
|
||||||
|
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2720,6 +2724,11 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
return userNamespace(userId);
|
return userNamespace(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Preview URL stays available until the custom domain is verified (not merely requested). */
|
||||||
|
private hasVerifiedCustomDomain(app: Application): boolean {
|
||||||
|
return !!(app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED);
|
||||||
|
}
|
||||||
|
|
||||||
private getClusterHostIp(kc: k8s.KubeConfig): string {
|
private getClusterHostIp(kc: k8s.KubeConfig): string {
|
||||||
const clusterServer = kc.getCurrentCluster()?.server || '';
|
const clusterServer = kc.getCurrentCluster()?.server || '';
|
||||||
try {
|
try {
|
||||||
@@ -2955,7 +2964,7 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get preview info for a deployed application.
|
* Get preview info for a deployed application.
|
||||||
* Patches the service to NodePort if needed, and returns the access URL.
|
* Returns ingress URL when available; only reads an existing NodePort (never patches ClusterIP).
|
||||||
*/
|
*/
|
||||||
async getPreviewInfo(
|
async getPreviewInfo(
|
||||||
app: Application,
|
app: Application,
|
||||||
@@ -2966,48 +2975,11 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
host: string;
|
host: string;
|
||||||
ingressUrl?: string;
|
ingressUrl?: string;
|
||||||
}> {
|
}> {
|
||||||
const { coreApi, networkingApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
|
||||||
const namespace = this.getUserNamespace(app.userId);
|
const namespace = this.getUserNamespace(app.userId);
|
||||||
const domain = this.configService.get('platform.domain');
|
const domain = this.configService.get('platform.domain');
|
||||||
const hostIp = this.getClusterHostIp(kc);
|
const hostIp = this.getClusterHostIp(kc);
|
||||||
|
|
||||||
// Read current service
|
|
||||||
let nodePort = 0;
|
|
||||||
try {
|
|
||||||
const svcResponse = await coreApi.readNamespacedService({
|
|
||||||
name: app.name,
|
|
||||||
namespace,
|
|
||||||
});
|
|
||||||
const svc = svcResponse;
|
|
||||||
|
|
||||||
if (svc.spec?.type === 'NodePort') {
|
|
||||||
// Already NodePort, read the assigned port
|
|
||||||
nodePort = svc.spec.ports?.[0]?.nodePort || 0;
|
|
||||||
} else {
|
|
||||||
// Patch ClusterIP → NodePort so we can access from outside
|
|
||||||
const patchBody = {
|
|
||||||
spec: {
|
|
||||||
type: 'NodePort',
|
|
||||||
ports: [
|
|
||||||
{
|
|
||||||
port: 80,
|
|
||||||
targetPort: app.port,
|
|
||||||
protocol: 'TCP',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const patchedResponse = await coreApi.patchNamespacedService({ name: app.name, namespace, body: patchBody }, k8s.setHeaderOptions('Content-Type', 'application/strategic-merge-patch+json'));
|
|
||||||
nodePort = patchedResponse.spec?.ports?.[0]?.nodePort || 0;
|
|
||||||
this.logger.log(`Patched service ${app.name} to NodePort: ${nodePort}`);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
this.logger.warn(`Failed to get/patch service for ${app.name}: ${e.message}`);
|
|
||||||
throw new Error(`Service not found for "${app.name}". Make sure the app is deployed.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build ingress URL (main / custom domain / preview host)
|
|
||||||
const subdomain = app.subdomain || app.name;
|
const subdomain = app.subdomain || app.name;
|
||||||
const verifiedCustomDomain = app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : null;
|
const verifiedCustomDomain = app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : null;
|
||||||
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain;
|
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain;
|
||||||
@@ -3020,8 +2992,23 @@ export class KubernetesService implements OnModuleInit {
|
|||||||
ingressUrl = `https://${namespacePrefix}-${previewNumber}.${previewRootDomain}`;
|
ingressUrl = `https://${namespacePrefix}-${previewNumber}.${previewRootDomain}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let nodePort = 0;
|
||||||
|
try {
|
||||||
|
const svcResponse = await coreApi.readNamespacedService({
|
||||||
|
name: app.name,
|
||||||
|
namespace,
|
||||||
|
});
|
||||||
|
if (svcResponse.spec?.type === 'NodePort') {
|
||||||
|
nodePort = svcResponse.spec.ports?.[0]?.nodePort || 0;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn(`Failed to read service for ${app.name}: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = ingressUrl || (nodePort > 0 ? `http://${hostIp}:${nodePort}` : '');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: `http://${hostIp}:${nodePort}`,
|
url,
|
||||||
nodePort,
|
nodePort,
|
||||||
host: hostIp,
|
host: hostIp,
|
||||||
ingressUrl,
|
ingressUrl,
|
||||||
|
|||||||
@@ -226,28 +226,38 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy {
|
|||||||
if (!app.billingCycle) return false;
|
if (!app.billingCycle) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cost = await this.billingService.calculateCostForApp(app);
|
return await this.appRepo.manager.transaction(async (em) => {
|
||||||
const hourlyAmount = cost.hourly;
|
const locked = await em.findOne(Application, {
|
||||||
if (hourlyAmount <= 0) return false;
|
where: { id: app.id },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
if (!locked?.billingCycle) return false;
|
||||||
|
if (locked.lifecycleStatus !== AppLifecycleStatus.ACTIVE) return false;
|
||||||
|
if (locked.planExpiresAt && locked.planExpiresAt.getTime() > Date.now()) {
|
||||||
|
return false; // another replica already renewed
|
||||||
|
}
|
||||||
|
|
||||||
// Check wallet balance
|
const cost = await this.billingService.calculateCostForApp(locked);
|
||||||
const { balance } = await this.billingService.getBalance(app.userId);
|
const hourlyAmount = cost.hourly;
|
||||||
if (balance < hourlyAmount) return false;
|
if (hourlyAmount <= 0) return false;
|
||||||
|
|
||||||
// Deduct and renew
|
const { balance } = await this.billingService.getBalance(locked.userId);
|
||||||
await this.billingService.deductWallet(
|
if (balance < hourlyAmount) return false;
|
||||||
app.userId,
|
|
||||||
hourlyAmount,
|
|
||||||
`Auto-renew hourly: ${app.name}`,
|
|
||||||
app.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
app.planExpiresAt = this.calculateExpiry(new Date(), BillingCycle.HOURLY);
|
await this.billingService.deductWallet(
|
||||||
app.lifecycleStatus = AppLifecycleStatus.ACTIVE;
|
locked.userId,
|
||||||
await this.appRepo.save(app);
|
hourlyAmount,
|
||||||
|
`Auto-renew hourly: ${locked.name}`,
|
||||||
|
locked.id,
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log(`Auto-renewed hourly plan for ${app.name} — deducted ${hourlyAmount} Toman`);
|
locked.planExpiresAt = this.calculateExpiry(new Date(), BillingCycle.HOURLY);
|
||||||
return true;
|
locked.lifecycleStatus = AppLifecycleStatus.ACTIVE;
|
||||||
|
await em.save(locked);
|
||||||
|
|
||||||
|
this.logger.log(`Auto-renewed hourly plan for ${locked.name} — deducted ${hourlyAmount} Toman`);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.logger.warn(`Auto-renew failed for ${app.name}: ${e.message}`);
|
this.logger.warn(`Auto-renew failed for ${app.name}: ${e.message}`);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { IsNull, LessThan, Repository } from 'typeorm';
|
import { IsNull, LessThan, Repository } from 'typeorm';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
import { VerificationCode } from './entities/verification-code.entity';
|
import { VerificationCode } from './entities/verification-code.entity';
|
||||||
import { User } from './entities/user.entity';
|
import { User } from './entities/user.entity';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
@@ -185,7 +186,7 @@ export class VerificationService implements OnModuleInit, OnModuleDestroy {
|
|||||||
{ consumedAt: new Date() },
|
{ consumedAt: new Date() },
|
||||||
);
|
);
|
||||||
|
|
||||||
const code = String(Math.floor(100000 + Math.random() * 900000)); // 6 digits
|
const code = String(crypto.randomInt(100000, 1000000)); // 6 digits, CSPRNG
|
||||||
const expiresAt = new Date(Date.now() + CODE_TTL_MS);
|
const expiresAt = new Date(Date.now() + CODE_TTL_MS);
|
||||||
const record = this.codeRepo.create({
|
const record = this.codeRepo.create({
|
||||||
userId,
|
userId,
|
||||||
@@ -208,30 +209,33 @@ export class VerificationService implements OnModuleInit, OnModuleDestroy {
|
|||||||
purpose: VerificationPurpose,
|
purpose: VerificationPurpose,
|
||||||
code: string,
|
code: string,
|
||||||
): Promise<VerificationCode> {
|
): Promise<VerificationCode> {
|
||||||
const record = await this.codeRepo.findOne({
|
return this.codeRepo.manager.transaction(async (em) => {
|
||||||
where: { userId, purpose, consumedAt: IsNull() },
|
const record = await em.findOne(VerificationCode, {
|
||||||
order: { createdAt: 'DESC' },
|
where: { userId, purpose, consumedAt: IsNull() },
|
||||||
});
|
order: { createdAt: 'DESC' },
|
||||||
|
lock: { mode: 'pessimistic_write' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record || record.expiresAt.getTime() < Date.now()) {
|
||||||
|
throw new BadRequestException('No active code — request a new one');
|
||||||
|
}
|
||||||
|
if (record.attempts >= MAX_VERIFY_ATTEMPTS) {
|
||||||
|
record.consumedAt = new Date();
|
||||||
|
await em.save(record);
|
||||||
|
throw new BadRequestException('Too many attempts — request a new code');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await bcrypt.compare(code, record.codeHash);
|
||||||
|
if (!ok) {
|
||||||
|
record.attempts += 1;
|
||||||
|
await em.save(record);
|
||||||
|
throw new BadRequestException('Invalid code');
|
||||||
|
}
|
||||||
|
|
||||||
if (!record || record.expiresAt.getTime() < Date.now()) {
|
|
||||||
throw new BadRequestException('No active code — request a new one');
|
|
||||||
}
|
|
||||||
if (record.attempts >= MAX_VERIFY_ATTEMPTS) {
|
|
||||||
record.consumedAt = new Date();
|
record.consumedAt = new Date();
|
||||||
await this.codeRepo.save(record);
|
await em.save(record);
|
||||||
throw new BadRequestException('Too many attempts — request a new code');
|
return record;
|
||||||
}
|
});
|
||||||
|
|
||||||
const ok = await bcrypt.compare(code, record.codeHash);
|
|
||||||
if (!ok) {
|
|
||||||
record.attempts += 1;
|
|
||||||
await this.codeRepo.save(record);
|
|
||||||
throw new BadRequestException('Invalid code');
|
|
||||||
}
|
|
||||||
|
|
||||||
record.consumedAt = new Date();
|
|
||||||
await this.codeRepo.save(record);
|
|
||||||
return record;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best-effort cleanup of long-expired codes (called opportunistically). */
|
/** Best-effort cleanup of long-expired codes (called opportunistically). */
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: development
|
||||||
PORT: 4000
|
PORT: 4000
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
|
|||||||
Reference in New Issue
Block a user