// FRONTEND_URL may hold a comma-separated list of origins (used for CORS). // Domain/host resolution must only ever look at a single URL, otherwise // `new URL('https://a.com,https://b.com')` yields the garbage hostname // "a.com,https" which then leaks into ingress hosts and gets rejected by k8s. function firstFrontendUrl(): string | undefined { const first = (process.env.FRONTEND_URL || '').split(',')[0]?.trim(); return first || undefined; } function resolvePlatformDomainFromEnv(): string { const frontendUrl = firstFrontendUrl(); if (frontendUrl) { try { const url = new URL(frontendUrl); if (url.hostname && !/^\d+\.\d+\.\d+\.\d+$/.test(url.hostname)) { return url.hostname; } } catch { // ignore parse errors and fall back to PLATFORM_DOMAIN } } return process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local'; } function stripLeadingSubdomain(hostname: string): string { const h = (hostname || '').toLowerCase(); if (!h) return h; // If it looks like an IP (IPv4 or IPv6), don't try to strip labels. if (h.includes(':') || /^\d+\.\d+\.\d+\.\d+$/.test(h)) { return h; } const parts = h.split('.').filter(Boolean); if (parts.length >= 3) { return parts.slice(1).join('.'); } return h; } function resolvePreviewRootDomainFromEnv(): string { // Explicit override takes priority — the base domain used for preview URLs // (e.g. PREVIEW_BASE_DOMAIN=3fase.ir) must be configurable, never hardcoded. const explicit = process.env.PREVIEW_BASE_DOMAIN; if (explicit && explicit.trim()) { return explicit.trim().toLowerCase(); } const frontendUrl = firstFrontendUrl(); if (frontendUrl) { try { const url = new URL(frontendUrl); if (url.hostname) { return stripLeadingSubdomain(url.hostname); } } catch { // ignore and fall back } } // Fallback: strip from PLATFORM_DOMAIN if it's already a subdomain host. return stripLeadingSubdomain(process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local'); } export default () => ({ nodeEnv: process.env.NODE_ENV || 'development', port: parseInt(process.env.PORT || '4000', 10), database: { host: process.env.DB_HOST || 'localhost', port: parseInt(process.env.DB_PORT || '5432', 10), username: process.env.DB_USERNAME || 'cloudhost', password: process.env.DB_PASSWORD || 'cloudhost_secret', name: process.env.DB_DATABASE || 'cloudhost', }, jwt: { secret: process.env.JWT_SECRET || 'default-jwt-secret', expiresIn: process.env.JWT_EXPIRES_IN || '1h', refreshSecret: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret', refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d', }, redis: { host: process.env.REDIS_HOST || 'localhost', port: parseInt(process.env.REDIS_PORT || '6379', 10), }, cluster: { /** AES-256-GCM key for encrypting stored kubeconfigs. Required in production. */ kubeconfigKey: process.env.CLUSTER_KUBECONFIG_KEY || '', }, // OTP SMS. Provider selectable via SMS_PROVIDER ('mizbansms' | 'kavenegar'). sms: { provider: (process.env.SMS_PROVIDER || 'mizbansms').trim().toLowerCase(), // Kavenegar (verify/lookup template API) — kept for fallback. kavenegarApiKey: process.env.KAVENEGAR_API_KEY || '', kavenegarOtpTemplate: process.env.KAVENEGAR_OTP_TEMPLATE || '', // MizbanSMS (services.mizbansms.com) — sends full message text over a // dedicated line, so the OTP wording is composed here, not from a template. mizban: { baseUrl: process.env.MIZBANSMS_BASE_URL || 'http://services.mizbansms.com', username: process.env.MIZBANSMS_USERNAME || '', password: process.env.MIZBANSMS_PASSWORD || '', from: process.env.MIZBANSMS_FROM || '5000467254', api: parseInt(process.env.MIZBANSMS_API || '2016', 10), userType: parseInt(process.env.MIZBANSMS_USERTYPE || '2', 10), // Per-message-kind wording. `{code}` (or `{{CODE}}`) is replaced with the // 6-digit OTP. \n is allowed for multi-line messages. templates: { register: process.env.MIZBANSMS_OTP_TEMPLATE_REGISTER || 'به ابربان خوش آمدید.\nکد تأیید شما: {code}', login: process.env.MIZBANSMS_OTP_TEMPLATE_LOGIN || 'رمز یکبار مصرف ابربان:\n{code}', changePhone: process.env.MIZBANSMS_OTP_TEMPLATE_CHANGE_PHONE || 'کد تأیید شماره جدید ابربان:\n{code}', }, }, }, registry: { /** In-cluster registry — Kaniko push and app image pull (same host). */ url: process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000', pullUrl: process.env.REGISTRY_PULL_URL || process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000', username: process.env.REGISTRY_USERNAME || 'admin', password: process.env.REGISTRY_PASSWORD || '', }, build: { namespace: process.env.BUILD_NAMESPACE || 'cloudhost-builds', serviceAccount: process.env.BUILD_SERVICE_ACCOUNT || 'kaniko-builder', }, elasticsearch: { /** API host for log search. In-cluster: cluster DNS. Local dev: loopback + auto port-forward. */ host: process.env.ELASTICSEARCH_HOST || (process.env.KUBERNETES_SERVICE_HOST ? 'elasticsearch.logging.svc.cluster.local' : '127.0.0.1'), port: parseInt(process.env.ELASTICSEARCH_PORT || '9200', 10), /** In development with loopback host, start kubectl port-forward on API boot (set false to manage manually). */ autoPortForward: process.env.ELASTICSEARCH_AUTO_PORT_FORWARD ?? 'true', password: process.env.ELASTIC_PASSWORD || 'CloudHost2024!Secure', fluentbitPassword: process.env.FLUENTBIT_PASSWORD || 'FluentBit2024!Writer', kibanaPassword: process.env.KIBANA_SYSTEM_PASSWORD || 'Kibana2024!System', /** Override when cluster nodes cannot reach docker.elastic.co (mirror to local registry). */ images: { elasticsearch: process.env.LOGGING_ELASTICSEARCH_IMAGE || 'docker.elastic.co/elasticsearch/elasticsearch:8.12.0', kibana: process.env.LOGGING_KIBANA_IMAGE || 'docker.elastic.co/kibana/kibana:8.12.0', busybox: process.env.LOGGING_BUSYBOX_IMAGE || 'busybox:1.36', curl: process.env.LOGGING_CURL_IMAGE || 'curlimages/curl:8.5.0', }, }, sourceStorage: { endpoint: process.env.SOURCE_STORAGE_ENDPOINT, region: process.env.SOURCE_STORAGE_REGION || 'us-east-1', bucket: process.env.SOURCE_STORAGE_BUCKET, accessKey: process.env.SOURCE_STORAGE_ACCESS_KEY, secretKey: process.env.SOURCE_STORAGE_SECRET_KEY, }, platform: { domain: resolvePlatformDomainFromEnv(), previewRootDomain: resolvePreviewRootDomainFromEnv(), /** * Ingress controller class used for app Ingress objects and the ACME * HTTP-01 solver. k3s ships Traefik by default, so we default to "traefik". * Set INGRESS_CLASS=nginx for clusters running ingress-nginx instead. */ ingressClass: (process.env.INGRESS_CLASS || 'traefik').trim().toLowerCase(), uploadDir: process.env.UPLOAD_DIR || './uploads', /** StorageClass for new PVCs; must support allowVolumeExpansion for disk resize */ storageClass: process.env.PLATFORM_STORAGE_CLASS || 'cloudhost-expandable', /** Install cloudhost-expandable StorageClass via Helm on each app deploy */ createStorageClass: process.env.PLATFORM_CREATE_STORAGE_CLASS !== 'false', storageProvisioner: process.env.PLATFORM_STORAGE_PROVISIONER || 'rancher.io/local-path', }, // Lifecycle defaults (can be overridden via PlatformSettings entity by admin) lifecycle: { // How long after plan expires to suspend the app (scale to 0) hourly: { suspendAfterMs: parseInt(process.env.LIFECYCLE_HOURLY_SUSPEND_MS || String(0), 10), // immediately on expiry deleteAfterMs: parseInt(process.env.LIFECYCLE_HOURLY_DELETE_MS || String(24 * 3600 * 1000), 10), // 24h after suspend }, monthly: { suspendAfterMs: parseInt(process.env.LIFECYCLE_MONTHLY_SUSPEND_MS || String(0), 10), deleteAfterMs: parseInt(process.env.LIFECYCLE_MONTHLY_DELETE_MS || String(3 * 24 * 3600 * 1000), 10), // 3 days }, yearly: { suspendAfterMs: parseInt(process.env.LIFECYCLE_YEARLY_SUSPEND_MS || String(0), 10), deleteAfterMs: parseInt(process.env.LIFECYCLE_YEARLY_DELETE_MS || String(7 * 24 * 3600 * 1000), 10), // 7 days }, // Cron interval for the lifecycle scanner scanIntervalMs: parseInt(process.env.LIFECYCLE_SCAN_INTERVAL_MS || String(60_000), 10), // 1 min }, });