From ad3cc6ad3bfef436f2e0e2a1f581308f43979579 Mon Sep 17 00:00:00 2001 From: dhaval-p-iqud Date: Wed, 5 Aug 2026 14:58:34 +0530 Subject: [PATCH 1/4] Feat: Enhance installation and uninstallation scripts with tracking and configuration improvements - Added installation lifecycle tracking to install.sh, including event types and version management. - Updated uninstall.sh to report uninstall events and clear tracking state. - Introduced DB_SSL configuration for PostgreSQL connections across various scripts and environment files. - Refactored environment variable handling for better clarity and defaults in .env files. - Improved error handling and logging in email service for better service availability checks. --- apps/control-panel-app/.env.example | 8 +- .../config/typeorm.config.ts | 4 +- .../deploy/.env.control-panel.example | 47 +- .../deploy/docker-compose.control-panel.yml | 10 +- apps/control-panel-app/docker-compose.yml | 1 + .../1785845114404-self-host-installations.ts | 154 ++++++ .../seeders/billing-cycles.seed.ts | 9 +- apps/control-panel-app/seeders/plans.seed.ts | 9 +- .../seeders/templates.seed.ts | 9 +- apps/control-panel-app/src/app.module.ts | 7 +- .../src/constants/env.constant.ts | 19 + .../src/modules/email/email.service.ts | 20 +- .../constants/public-messages.constants.ts | 3 + .../public-installations.controller.ts | 62 +++ .../dto/record-installation-event.dto.ts | 63 +++ .../entities/self-host-installation.entity.ts | 83 ++++ .../enums/installation-event-type.enum.ts | 8 + .../src/modules/public/public.module.ts | 21 +- .../self-host-installation.service.ts | 74 +++ install.sh | 448 +++++++++++++++++- uninstall.sh | 186 +++++++- 21 files changed, 1181 insertions(+), 64 deletions(-) create mode 100644 apps/control-panel-app/migrations/1785845114404-self-host-installations.ts create mode 100644 apps/control-panel-app/src/modules/public/controllers/public-installations.controller.ts create mode 100644 apps/control-panel-app/src/modules/public/dto/record-installation-event.dto.ts create mode 100644 apps/control-panel-app/src/modules/public/entities/self-host-installation.entity.ts create mode 100644 apps/control-panel-app/src/modules/public/enums/installation-event-type.enum.ts create mode 100644 apps/control-panel-app/src/modules/public/services/self-host-installation.service.ts diff --git a/apps/control-panel-app/.env.example b/apps/control-panel-app/.env.example index 81496fbc..ae075bfe 100644 --- a/apps/control-panel-app/.env.example +++ b/apps/control-panel-app/.env.example @@ -6,6 +6,7 @@ DB_PORT=5432 DB_USERNAME=postgres DB_PASSWORD=postgres DB_DATABASE=kubeara-dev +DB_SSL=false ENCRYPTION_SECRET=change-me-to-a-long-random-secret JWT_SECRET=change-me-jwt-secret JWT_REFRESH_SECRET=change-me-jwt-refresh-secret @@ -19,8 +20,9 @@ SMTP_SERVER=smtp-relay.brevo.com SMTP_PORT=587 SMTP_LOGIN=your-smtp-login@example.com SMTP_PASSWORD=your_smtp_password -BREVO_API_KEY=your_brevo_api_key -BREVO_FROM_EMAIL=noreply@example.com +# Optional: leave empty to disable email delivery without blocking startup. +BREVO_API_KEY= +BREVO_FROM_EMAIL= BREVO_FROM_NAME=Kubeara # Zoho Desk (POST /api/public/support and /api/public/service-requests — creates tickets in Zoho Desk; nothing stored locally) @@ -29,9 +31,7 @@ ZOHO_CLIENT_SECRET=your_zoho_client_secret ZOHO_REFRESH_TOKEN=your_zoho_refresh_token ZOHO_ORGANIZATION_ID=your_zoho_organization_id ZOHO_DEPARTMENT_ID=your_zoho_department_id -# e.g. https://accounts.zoho.com | https://accounts.zoho.eu | https://accounts.zoho.in ZOHO_ACCOUNTS_BASE_URL=https://accounts.zoho.in -# e.g. https://desk.zoho.com/api/v1 | https://desk.zoho.eu/api/v1 ZOHO_DESK_BASE_URL=https://desk.zoho.in/api/v1 OTP_EXPIRES_IN=2m diff --git a/apps/control-panel-app/config/typeorm.config.ts b/apps/control-panel-app/config/typeorm.config.ts index 98316fd8..0d4b3424 100644 --- a/apps/control-panel-app/config/typeorm.config.ts +++ b/apps/control-panel-app/config/typeorm.config.ts @@ -1,7 +1,7 @@ import * as path from "path"; import * as dotenv from "dotenv"; import { DataSource } from "typeorm"; -import { isProductionEnv } from "../src/constants/env.constant"; +import { isDbSslEnabled } from "../src/constants/env.constant"; dotenv.config({ path: path.join(process.cwd(), "apps", "control-panel-app", ".env"), @@ -21,7 +21,7 @@ export default new DataSource({ password: getRequiredEnv("DB_PASSWORD"), database: getRequiredEnv("DB_DATABASE"), synchronize: false, - ...(isProductionEnv(getRequiredEnv("NODE_ENV")) + ...(isDbSslEnabled(process.env.DB_SSL, process.env.NODE_ENV) ? { ssl: { rejectUnauthorized: false } } : {}), entities: [ diff --git a/apps/control-panel-app/deploy/.env.control-panel.example b/apps/control-panel-app/deploy/.env.control-panel.example index 39291a80..61f85a11 100644 --- a/apps/control-panel-app/deploy/.env.control-panel.example +++ b/apps/control-panel-app/deploy/.env.control-panel.example @@ -3,6 +3,9 @@ # # ENCRYPTION_SECRET: use the same value on the agent (.env.agent). +# Compose Postgres has no TLS. Keep development so Hub images connect without SSL. +NODE_ENV=development + KUBEARA_CONTROL_PANEL_IMAGE=kubeara/control-panel:prod KUBEARA_CONSOLE_IMAGE=kubeara/console:prod # linux/amd64 on Apple Silicon until multi-arch images are pulled; use linux/arm64 when available @@ -19,39 +22,42 @@ ENCRYPTION_SECRET=change-me-to-a-long-random-secret JWT_SECRET=change-me-jwt-secret JWT_REFRESH_SECRET=change-me-jwt-refresh-secret -# HTTP-only cookie authentication +# HTTP-only cookie authentication (COOKIE_SECURE=false for local HTTP) ACCESS_TOKEN_COOKIE_NAME=kubeara_access_token REFRESH_TOKEN_COOKIE_NAME=kubeara_refresh_token ACCESS_TOKEN_EXPIRES_IN=15m REFRESH_TOKEN_EXPIRES_IN=7d COOKIE_DOMAIN= -COOKIE_SECURE=true +COOKIE_SECURE=false COOKIE_SAME_SITE=lax +OTP_EXPIRES_IN=2m + # Browser origins for public marketing APIs (/api/public/*) -PUBLIC_API_ALLOWED_ORIGINS=https://kubeara.dev,https://www.kubeara.dev,https://app.kubeara.dev,http://localhost:3000 +PUBLIC_API_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080,http://127.0.0.1:3000,http://127.0.0.1:8080 # Optional: broader list for console/auth (defaults to PUBLIC_API_ALLOWED_ORIGINS) -CORS_ALLOWED_ORIGINS=https://kubeara.dev,https://www.kubeara.dev,https://app.kubeara.dev +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080,http://127.0.0.1:3000,http://127.0.0.1:8080 # Public URL remote agents use to reach this control panel (required for onboard agent install). -CONTROL_PANEL_URL=http://your-control-panel-host:3000 +CONTROL_PANEL_URL=http://localhost:3000 -# Stripe subscription billing -STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key -STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key -STRIPE_WEBHOOK_SECRET=whsec_your_stripe_webhook_secret -STRIPE_CHECKOUT_PAYMENT_METHODS=card +# Optional email — empty keys keep older Hub images (getOrThrow BREVO_*) from crashing +BREVO_API_KEY= +BREVO_FROM_EMAIL= +BREVO_FROM_NAME=Kubeara -# Zoho Desk (POST /api/public/support and /api/public/service-requests — creates tickets in Zoho Desk; nothing stored locally) -ZOHO_CLIENT_ID=your_zoho_client_id -ZOHO_CLIENT_SECRET=your_zoho_client_secret -ZOHO_REFRESH_TOKEN=your_zoho_refresh_token -ZOHO_ORGANIZATION_ID=your_zoho_organization_id -ZOHO_DEPARTMENT_ID=your_zoho_department_id -# e.g. https://accounts.zoho.com | https://accounts.zoho.eu | https://accounts.zoho.in -ZOHO_ACCOUNTS_BASE_URL=https://accounts.zoho.in -# e.g. https://desk.zoho.com/api/v1 | https://desk.zoho.eu/api/v1 -ZOHO_DESK_BASE_URL=https://desk.zoho.in/api/v1 +# Stripe / Zoho optional — leave as placeholders or unset; integrations soft-disable +# STRIPE_PUBLISHABLE_KEY= +# STRIPE_SECRET_KEY= +# STRIPE_WEBHOOK_SECRET= +# STRIPE_CHECKOUT_PAYMENT_METHODS=card +# ZOHO_CLIENT_ID= +# ZOHO_CLIENT_SECRET= +# ZOHO_REFRESH_TOKEN= +# ZOHO_ORGANIZATION_ID= +# ZOHO_DEPARTMENT_ID= +# ZOHO_ACCOUNTS_BASE_URL=https://accounts.zoho.in +# ZOHO_DESK_BASE_URL=https://desk.zoho.in/api/v1 # Values below are used inside containers (DB_HOST must be the compose service name). DB_HOST=postgres @@ -59,6 +65,7 @@ DB_PORT=5432 DB_USERNAME=postgres DB_PASSWORD=postgres DB_DATABASE=kubeara +DB_SSL=false # Grafana Cloud Loki (optional — see deploy/.env.monitoring.example) # GRAFANA_CLOUD_LOKI_URL= diff --git a/apps/control-panel-app/deploy/docker-compose.control-panel.yml b/apps/control-panel-app/deploy/docker-compose.control-panel.yml index 2af79414..db36bf77 100644 --- a/apps/control-panel-app/deploy/docker-compose.control-panel.yml +++ b/apps/control-panel-app/deploy/docker-compose.control-panel.yml @@ -37,13 +37,15 @@ services: env_file: - .env.control-panel environment: - NODE_ENV: production + # development: Hub images enable SSL when NODE_ENV=production; compose Postgres has no TLS. + NODE_ENV: ${NODE_ENV:-development} DOCKER_ENV: "true" DB_HOST: ${DB_HOST:-postgres} DB_PORT: 5432 DB_USERNAME: ${DB_USERNAME:-postgres} DB_PASSWORD: ${DB_PASSWORD:-postgres} DB_DATABASE: ${DB_DATABASE:-kubeara} + DB_SSL: ${DB_SSL:-false} ENCRYPTION_SECRET: ${ENCRYPTION_SECRET:?Set ENCRYPTION_SECRET in .env.control-panel} volumes: - ./.env.control-panel:/app/apps/control-panel-app/.env:ro @@ -68,7 +70,7 @@ services: env_file: - .env.control-panel environment: - NODE_ENV: production + NODE_ENV: ${NODE_ENV:-development} DOCKER_ENV: "true" PORT: ${PORT:-3000} DB_HOST: ${DB_HOST:-postgres} @@ -76,8 +78,8 @@ services: DB_USERNAME: ${DB_USERNAME:-postgres} DB_PASSWORD: ${DB_PASSWORD:-postgres} DB_DATABASE: ${DB_DATABASE:-kubeara} + DB_SSL: ${DB_SSL:-false} ENCRYPTION_SECRET: ${ENCRYPTION_SECRET:?Set ENCRYPTION_SECRET in .env.control-panel} - # Defaults for local/dev; install.sh replaces with openssl-generated secrets. Override in .env.control-panel for production. JWT_SECRET: ${JWT_SECRET:-change-me-jwt-secret} JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-change-me-jwt-refresh-secret} ACCESS_TOKEN_COOKIE_NAME: ${ACCESS_TOKEN_COOKIE_NAME:-kubeara_access_token} @@ -85,7 +87,7 @@ services: ACCESS_TOKEN_EXPIRES_IN: ${ACCESS_TOKEN_EXPIRES_IN:-15m} REFRESH_TOKEN_EXPIRES_IN: ${REFRESH_TOKEN_EXPIRES_IN:-7d} COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} - COOKIE_SECURE: ${COOKIE_SECURE:-true} + COOKIE_SECURE: ${COOKIE_SECURE:-false} COOKIE_SAME_SITE: ${COOKIE_SAME_SITE:-lax} CONTROL_PANEL_URL: ${CONTROL_PANEL_URL:-} KUBEARA_AGENT_IMAGE: ${KUBEARA_AGENT_IMAGE:-kubeara/agent:prod} diff --git a/apps/control-panel-app/docker-compose.yml b/apps/control-panel-app/docker-compose.yml index bf07034d..205ec881 100644 --- a/apps/control-panel-app/docker-compose.yml +++ b/apps/control-panel-app/docker-compose.yml @@ -42,6 +42,7 @@ services: DB_PASSWORD: ${DB_PASSWORD:-postgres} DB_DATABASE: ${DB_DATABASE:-templates} ENCRYPTION_SECRET: ${ENCRYPTION_SECRET} + DB_SSL: ${DB_SSL:-false} depends_on: postgres: condition: service_healthy diff --git a/apps/control-panel-app/migrations/1785845114404-self-host-installations.ts b/apps/control-panel-app/migrations/1785845114404-self-host-installations.ts new file mode 100644 index 00000000..93174fe0 --- /dev/null +++ b/apps/control-panel-app/migrations/1785845114404-self-host-installations.ts @@ -0,0 +1,154 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm"; + +export class SelfHostInstallations1785845114404 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "selfHostInstallations", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + generationStrategy: "uuid", + default: "uuid_generate_v4()", + }, + { + name: "installationId", + type: "uuid", + isNullable: false, + }, + { + name: "eventType", + type: "varchar", + length: "32", + isNullable: false, + }, + { + name: "version", + type: "varchar", + length: "64", + isNullable: false, + }, + { + name: "previousVersion", + type: "varchar", + length: "64", + isNullable: true, + }, + { + name: "ipAddress", + type: "varchar", + length: "255", + isNullable: false, + }, + { + name: "userAgent", + type: "varchar", + length: "512", + isNullable: true, + }, + { + name: "os", + type: "varchar", + length: "128", + isNullable: true, + }, + { + name: "osVersion", + type: "varchar", + length: "128", + isNullable: true, + }, + { + name: "architecture", + type: "varchar", + length: "64", + isNullable: true, + }, + { + name: "dockerVersion", + type: "varchar", + length: "64", + isNullable: true, + }, + { + name: "composeVersion", + type: "varchar", + length: "64", + isNullable: true, + }, + { + name: "status", + type: "varchar", + length: "50", + default: "'ACTIVE'", + isNullable: false, + }, + { + name: "metadata", + type: "jsonb", + isNullable: true, + }, + { + name: "createdAt", + type: "bigint", + isNullable: false, + }, + { + name: "updatedAt", + type: "bigint", + isNullable: false, + }, + { + name: "deletedAt", + type: "bigint", + isNullable: true, + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + "selfHostInstallations", + new TableIndex({ + name: "IDX_self_host_installations_installationId", + columnNames: ["installationId"], + }), + ); + + await queryRunner.createIndex( + "selfHostInstallations", + new TableIndex({ + name: "IDX_self_host_installations_eventType", + columnNames: ["eventType"], + }), + ); + + await queryRunner.createIndex( + "selfHostInstallations", + new TableIndex({ + name: "IDX_self_host_installations_createdAt", + columnNames: ["createdAt"], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex( + "selfHostInstallations", + "IDX_self_host_installations_createdAt", + ); + await queryRunner.dropIndex( + "selfHostInstallations", + "IDX_self_host_installations_eventType", + ); + await queryRunner.dropIndex( + "selfHostInstallations", + "IDX_self_host_installations_installationId", + ); + + await queryRunner.dropTable("selfHostInstallations"); + } +} diff --git a/apps/control-panel-app/seeders/billing-cycles.seed.ts b/apps/control-panel-app/seeders/billing-cycles.seed.ts index e9b3c8fc..92ca5f9f 100644 --- a/apps/control-panel-app/seeders/billing-cycles.seed.ts +++ b/apps/control-panel-app/seeders/billing-cycles.seed.ts @@ -9,7 +9,7 @@ import dayjs from "dayjs"; import { BillingCycleEntity } from "../src/modules/subscriptions/entities/billing-cycle.entity"; import { BillingCycleSlug } from "../src/modules/subscriptions/enums/billing-cycle.enum"; import { EntityStatus } from "../src/common/entity/base.entity"; -import { isProductionEnv } from "../src/constants/env.constant"; +import { isDbSslEnabled } from "../src/constants/env.constant"; const ROOT_DIR = process.cwd(); const APP_ENV_PATH = path.join(ROOT_DIR, "apps/control-panel-app/.env"); @@ -53,7 +53,10 @@ function loadEnv(): ConfigService { export async function seedBillingCycles(): Promise { const configService = loadEnv(); - const isProduction = isProductionEnv(configService.get("NODE_ENV")); + const useSsl = isDbSslEnabled( + configService.get("DB_SSL"), + configService.get("NODE_ENV"), + ); const ds = new DataSource({ type: "postgres", host: configService.get("DB_HOST"), @@ -63,7 +66,7 @@ export async function seedBillingCycles(): Promise { database: configService.get("DB_DATABASE"), entities: [BillingCycleEntity], synchronize: false, - ...(isProduction ? { ssl: { rejectUnauthorized: false } } : {}), + ...(useSsl ? { ssl: { rejectUnauthorized: false } } : {}), }); if (!ds.isInitialized) { diff --git a/apps/control-panel-app/seeders/plans.seed.ts b/apps/control-panel-app/seeders/plans.seed.ts index f1afa118..8295b6d0 100644 --- a/apps/control-panel-app/seeders/plans.seed.ts +++ b/apps/control-panel-app/seeders/plans.seed.ts @@ -10,7 +10,7 @@ import { PlanEntity } from "../src/modules/subscriptions/entities/plan.entity"; import { PlanSlug } from "../src/modules/subscriptions/enums/plan-slug.enum"; import { EntityStatus } from "../src/common/entity/base.entity"; import { PLAN_DEFINITIONS } from "./plan-definitions.defaults"; -import { isProductionEnv } from "../src/constants/env.constant"; +import { isDbSslEnabled } from "../src/constants/env.constant"; const ROOT_DIR = process.cwd(); const APP_ENV_PATH = path.join(ROOT_DIR, "apps/control-panel-app/.env"); @@ -26,7 +26,10 @@ const LEGACY_SLUGS = ["starter", "pro", "max", "business"] as const; export async function seedPlans(): Promise { const configService = loadEnv(); - const isProduction = isProductionEnv(configService.get("NODE_ENV")); + const useSsl = isDbSslEnabled( + configService.get("DB_SSL"), + configService.get("NODE_ENV"), + ); const ds = new DataSource({ type: "postgres", host: configService.get("DB_HOST"), @@ -36,7 +39,7 @@ export async function seedPlans(): Promise { database: configService.get("DB_DATABASE"), entities: [PlanEntity], synchronize: false, - ...(isProduction ? { ssl: { rejectUnauthorized: false } } : {}), + ...(useSsl ? { ssl: { rejectUnauthorized: false } } : {}), }); if (!ds.isInitialized) { diff --git a/apps/control-panel-app/seeders/templates.seed.ts b/apps/control-panel-app/seeders/templates.seed.ts index 70a0ee0f..15547ec2 100644 --- a/apps/control-panel-app/seeders/templates.seed.ts +++ b/apps/control-panel-app/seeders/templates.seed.ts @@ -22,7 +22,7 @@ const ROOT_DIR = process.cwd(); const ROOT_ENV_PATH = path.join(ROOT_DIR, ".env"); const APP_ENV_PATH = path.join(ROOT_DIR, "apps/control-panel-app/.env"); import dayjs from "dayjs"; -import { isProductionEnv } from "@control-panel/constants/env.constant"; +import { isDbSslEnabled } from "@control-panel/constants/env.constant"; /** * Database connection settings required before seeding can start. @@ -157,7 +157,10 @@ function validateRequiredConfig(configService: ConfigService): void { */ function createDataSource(configService: ConfigService): DataSource { try { - const isProduction = isProductionEnv(configService.get("NODE_ENV")); + const useSsl = isDbSslEnabled( + configService.get("DB_SSL"), + configService.get("NODE_ENV"), + ); return new DataSource({ type: "postgres", host: configService.get("DB_HOST") as string, @@ -166,7 +169,7 @@ function createDataSource(configService: ConfigService): DataSource { password: configService.get("DB_PASSWORD") as string, database: configService.get("DB_DATABASE") as string, synchronize: false, - ...(isProduction ? { ssl: { rejectUnauthorized: false } } : {}), + ...(useSsl ? { ssl: { rejectUnauthorized: false } } : {}), entities: [ServiceTemplateEntity], }); } catch (error: unknown) { diff --git a/apps/control-panel-app/src/app.module.ts b/apps/control-panel-app/src/app.module.ts index 765594f4..4557366e 100644 --- a/apps/control-panel-app/src/app.module.ts +++ b/apps/control-panel-app/src/app.module.ts @@ -21,7 +21,7 @@ import { SubscriptionsModule } from "./modules/subscriptions/subscriptions.modul import { ActivityModule } from "./modules/activity/activity.module"; import { LokiLoggerModule } from "./modules/loki-logger"; import { AppController } from "./app.controller"; -import { isProductionEnv } from "@control-panel/constants/env.constant"; +import { isDbSslEnabled } from "@control-panel/constants/env.constant"; import { CronModule } from "./cron/cron.module"; import { PublicModule } from "./modules/public/public.module"; @@ -36,7 +36,8 @@ import { PublicModule } from "./modules/public/public.module"; inject: [ConfigService], useFactory: (configService: ConfigService) => { try { - const isProduction = isProductionEnv( + const useSsl = isDbSslEnabled( + configService.get("DB_SSL"), configService.get("NODE_ENV"), ); @@ -51,7 +52,7 @@ import { PublicModule } from "./modules/public/public.module"; migrationsRun: false, entities: [__dirname + "/modules/**/entities/*.entity{.ts,.js}"], migrations: [path.join(__dirname, "../../migrations/*{.js,.ts}")], - ...(isProduction ? { ssl: { rejectUnauthorized: false } } : {}), + ...(useSsl ? { ssl: { rejectUnauthorized: false } } : {}), }; } catch (error) { throw new Error( diff --git a/apps/control-panel-app/src/constants/env.constant.ts b/apps/control-panel-app/src/constants/env.constant.ts index 15f55ae7..e33f5d2f 100644 --- a/apps/control-panel-app/src/constants/env.constant.ts +++ b/apps/control-panel-app/src/constants/env.constant.ts @@ -7,4 +7,23 @@ export function isProductionEnv(nodeEnv: string | undefined): boolean { return nodeEnv === NODE_ENV.PRODUCTION; } +/** + * Enable Postgres SSL only when DB_SSL=true. + * Self-hosted compose sets DB_SSL=false so local Postgres works even if + * NODE_ENV=production. When DB_SSL is unset, keep legacy production behavior. + */ +export function isDbSslEnabled( + dbSsl: string | undefined | null, + nodeEnv?: string | null, +): boolean { + const normalized = dbSsl?.trim().toLowerCase(); + if (normalized === "true") { + return true; + } + if (normalized === "false") { + return false; + } + return isProductionEnv(nodeEnv ?? undefined); +} + export const SALT_ROUNDS = 10; diff --git a/apps/control-panel-app/src/modules/email/email.service.ts b/apps/control-panel-app/src/modules/email/email.service.ts index d9a2cde0..6968acd0 100644 --- a/apps/control-panel-app/src/modules/email/email.service.ts +++ b/apps/control-panel-app/src/modules/email/email.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, ServiceUnavailableException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { BrevoClient } from "@getbrevo/brevo"; import { OTP_EMAIL_COPY } from "./email.constants"; @@ -11,18 +11,16 @@ import { @Injectable() export class EmailService { - private readonly brevo: BrevoClient; - private readonly fromEmail: string; + private readonly brevo: BrevoClient | null; + private readonly fromEmail: string | undefined; private readonly fromName: string; constructor(private readonly configService: ConfigService) { - const apiKey = this.configService.getOrThrow("BREVO_API_KEY"); - - this.fromEmail = this.configService.getOrThrow("BREVO_FROM_EMAIL"); + const apiKey = this.configService.get("BREVO_API_KEY")?.trim(); + this.fromEmail = this.configService.get("BREVO_FROM_EMAIL")?.trim(); this.fromName = - this.configService.get("BREVO_FROM_NAME") ?? "Kubeara"; - - this.brevo = new BrevoClient({ apiKey }); + this.configService.get("BREVO_FROM_NAME")?.trim() || "Kubeara"; + this.brevo = apiKey && this.fromEmail ? new BrevoClient({ apiKey }) : null; } private buildOtpEmailHtml(input: { @@ -57,6 +55,10 @@ export class EmailService { otp: string; purposeLabel: string; }): Promise { + if (!this.brevo || !this.fromEmail) { + throw new ServiceUnavailableException("Email service is not configured."); + } + const subject = `Your ${input.purposeLabel} code`; const htmlContent = this.buildOtpEmailHtml(input); diff --git a/apps/control-panel-app/src/modules/public/constants/public-messages.constants.ts b/apps/control-panel-app/src/modules/public/constants/public-messages.constants.ts index 44b5074a..45baf9ef 100644 --- a/apps/control-panel-app/src/modules/public/constants/public-messages.constants.ts +++ b/apps/control-panel-app/src/modules/public/constants/public-messages.constants.ts @@ -5,4 +5,7 @@ export const PUBLIC_MESSAGES = { SERVICE_REQUEST: { SUBMITTED: "Service request submitted successfully.", }, + INSTALLATION: { + EVENT_RECORDED: "Installation event recorded successfully.", + }, } as const; diff --git a/apps/control-panel-app/src/modules/public/controllers/public-installations.controller.ts b/apps/control-panel-app/src/modules/public/controllers/public-installations.controller.ts new file mode 100644 index 00000000..8d303de3 --- /dev/null +++ b/apps/control-panel-app/src/modules/public/controllers/public-installations.controller.ts @@ -0,0 +1,62 @@ +import { Body, Controller, Logger, Post, Req } from "@nestjs/common"; +import type { Request } from "express"; + +import { ServiceResponse } from "@control-panel/common/interfaces/success-response.interface"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; + +import { RecordInstallationEventDto } from "../dto/record-installation-event.dto"; +import { SelfHostInstallationService } from "../services/self-host-installation.service"; + +@Controller("public/installations") +export class InstallationsController { + private readonly logger = new Logger(InstallationsController.name); + + constructor( + private readonly selfHostInstallationService: SelfHostInstallationService, + ) {} + + /** + * Record a self-hosted installation lifecycle event (install, upgrade, uninstall). + * Public — no authentication. IP is taken from the request, never from the body. + */ + @Post("events") + async recordEvent( + @Body() dto: RecordInstallationEventDto, + @Req() request: Request, + ): Promise> { + try { + return await this.selfHostInstallationService.recordEvent( + dto, + resolveClientIp(request), + ); + } catch (error) { + this.logger.error( + `Record installation event failed: ${toErrorMessage(error)}`, + ); + throw error; + } + } +} + +/** + * Derive the client IP from the HTTP request. Does not trust body-supplied values. + */ +function resolveClientIp(request: Request): string { + const forwarded = request.headers["x-forwarded-for"]; + + if (typeof forwarded === "string" && forwarded.trim().length > 0) { + const firstHop = forwarded.split(",")[0]?.trim(); + if (firstHop) { + return firstHop; + } + } + + if (Array.isArray(forwarded) && forwarded[0]) { + const firstHop = forwarded[0].split(",")[0]?.trim(); + if (firstHop) { + return firstHop; + } + } + + return request.ip || request.socket.remoteAddress || "unknown"; +} diff --git a/apps/control-panel-app/src/modules/public/dto/record-installation-event.dto.ts b/apps/control-panel-app/src/modules/public/dto/record-installation-event.dto.ts new file mode 100644 index 00000000..c360658e --- /dev/null +++ b/apps/control-panel-app/src/modules/public/dto/record-installation-event.dto.ts @@ -0,0 +1,63 @@ +import { + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MaxLength, + ValidateIf, +} from "class-validator"; + +import { InstallationEventType } from "../enums/installation-event-type.enum"; + +export class RecordInstallationEventDto { + @IsUUID() + installationId!: string; + + @IsEnum(InstallationEventType) + eventType!: InstallationEventType; + + @IsString() + @IsNotEmpty() + @MaxLength(64) + version!: string; + + @ValidateIf( + (dto: RecordInstallationEventDto) => + dto.eventType === InstallationEventType.UPGRADE, + ) + @IsString() + @IsNotEmpty() + @MaxLength(64) + previousVersion?: string | null; + + @IsOptional() + @IsString() + @MaxLength(512) + userAgent?: string; + + @IsOptional() + @IsString() + @MaxLength(128) + os?: string; + + @IsOptional() + @IsString() + @MaxLength(128) + osVersion?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + architecture?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + dockerVersion?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + composeVersion?: string; +} diff --git a/apps/control-panel-app/src/modules/public/entities/self-host-installation.entity.ts b/apps/control-panel-app/src/modules/public/entities/self-host-installation.entity.ts new file mode 100644 index 00000000..f3079262 --- /dev/null +++ b/apps/control-panel-app/src/modules/public/entities/self-host-installation.entity.ts @@ -0,0 +1,83 @@ +import { Column, Entity, Index } from "typeorm"; +import { + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from "class-validator"; + +import { BaseEntity } from "@control-panel/common/entity/base.entity"; + +import { InstallationEventType } from "../enums/installation-event-type.enum"; + +/** + * One row per self-hosted/local Kubeara installation lifecycle event. + * + * BaseEntity also provides: id, status, metadata, createdAt, updatedAt, deletedAt. + */ +@Entity({ name: "selfHostInstallations" }) +@Index("IDX_self_host_installations_installationId", ["installationId"]) +@Index("IDX_self_host_installations_eventType", ["eventType"]) +@Index("IDX_self_host_installations_createdAt", ["createdAt"]) +export class SelfHostInstallationEntity extends BaseEntity { + @IsUUID() + @Column({ type: "uuid" }) + installationId!: string; + + @IsEnum(InstallationEventType) + @Column({ type: "varchar", length: 32 }) + eventType!: InstallationEventType; + + @IsString() + @MaxLength(64) + @Column({ type: "varchar", length: 64 }) + version!: string; + + @IsOptional() + @IsString() + @MaxLength(64) + @Column({ type: "varchar", length: 64, nullable: true }) + previousVersion!: string | null; + + @IsString() + @MaxLength(255) + @Column({ type: "varchar", length: 255 }) + ipAddress!: string; + + @IsOptional() + @IsString() + @MaxLength(512) + @Column({ type: "varchar", length: 512, nullable: true }) + userAgent!: string | null; + + @IsOptional() + @IsString() + @MaxLength(128) + @Column({ type: "varchar", length: 128, nullable: true }) + os!: string | null; + + @IsOptional() + @IsString() + @MaxLength(128) + @Column({ type: "varchar", length: 128, nullable: true }) + osVersion!: string | null; + + @IsOptional() + @IsString() + @MaxLength(64) + @Column({ type: "varchar", length: 64, nullable: true }) + architecture!: string | null; + + @IsOptional() + @IsString() + @MaxLength(64) + @Column({ type: "varchar", length: 64, nullable: true }) + dockerVersion!: string | null; + + @IsOptional() + @IsString() + @MaxLength(64) + @Column({ type: "varchar", length: 64, nullable: true }) + composeVersion!: string | null; +} diff --git a/apps/control-panel-app/src/modules/public/enums/installation-event-type.enum.ts b/apps/control-panel-app/src/modules/public/enums/installation-event-type.enum.ts new file mode 100644 index 00000000..16395091 --- /dev/null +++ b/apps/control-panel-app/src/modules/public/enums/installation-event-type.enum.ts @@ -0,0 +1,8 @@ +/** + * Lifecycle events for a self-hosted Kubeara installation. + */ +export enum InstallationEventType { + INSTALL = "INSTALL", + UPGRADE = "UPGRADE", + UNINSTALL = "UNINSTALL", +} diff --git a/apps/control-panel-app/src/modules/public/public.module.ts b/apps/control-panel-app/src/modules/public/public.module.ts index 37e88ae9..4beee1d3 100644 --- a/apps/control-panel-app/src/modules/public/public.module.ts +++ b/apps/control-panel-app/src/modules/public/public.module.ts @@ -1,15 +1,30 @@ import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; import { KubearaPublicOriginGuard } from "@control-panel/common/guards/kubeara-public-origin.guard"; import { ServiceTemplateModule } from "@control-panel/modules/service-template/service-template.module"; +import { InstallationsController } from "./controllers/public-installations.controller"; import { SupportController } from "./controllers/public-support.controller"; import { TemplatesController } from "./controllers/public-templates.controller"; +import { SelfHostInstallationEntity } from "./entities/self-host-installation.entity"; +import { SelfHostInstallationService } from "./services/self-host-installation.service"; import { ZohoDeskService } from "./services/zoho-desk.service"; @Module({ - imports: [ServiceTemplateModule], - controllers: [TemplatesController, SupportController], - providers: [KubearaPublicOriginGuard, ZohoDeskService], + imports: [ + ServiceTemplateModule, + TypeOrmModule.forFeature([SelfHostInstallationEntity]), + ], + controllers: [ + TemplatesController, + SupportController, + InstallationsController, + ], + providers: [ + KubearaPublicOriginGuard, + ZohoDeskService, + SelfHostInstallationService, + ], }) export class PublicModule {} diff --git a/apps/control-panel-app/src/modules/public/services/self-host-installation.service.ts b/apps/control-panel-app/src/modules/public/services/self-host-installation.service.ts new file mode 100644 index 00000000..7c4399d3 --- /dev/null +++ b/apps/control-panel-app/src/modules/public/services/self-host-installation.service.ts @@ -0,0 +1,74 @@ +import { + Injectable, + InternalServerErrorException, + Logger, +} from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { EntityStatus } from "@control-panel/common/entity/entity-status"; +import { ServiceResponse } from "@control-panel/common/interfaces/success-response.interface"; +import { toErrorMessage } from "@control-panel/common/utils/error.util"; + +import { PUBLIC_MESSAGES } from "../constants/public-messages.constants"; +import { RecordInstallationEventDto } from "../dto/record-installation-event.dto"; +import { SelfHostInstallationEntity } from "../entities/self-host-installation.entity"; +import { InstallationEventType } from "../enums/installation-event-type.enum"; + +@Injectable() +export class SelfHostInstallationService { + private readonly logger = new Logger(SelfHostInstallationService.name); + + constructor( + @InjectRepository(SelfHostInstallationEntity) + private readonly selfHostInstallationRepository: Repository, + ) {} + + /** + * Persist a self-hosted installation lifecycle event. + * + * @param input - Installer-supplied event payload (no client IP). + * @param ipAddress - Client IP derived from the incoming request. + */ + async recordEvent( + input: RecordInstallationEventDto, + ipAddress: string, + ): Promise> { + try { + const previousVersion = + input.eventType === InstallationEventType.UPGRADE + ? (input.previousVersion ?? null) + : null; + + const event = this.selfHostInstallationRepository.create({ + installationId: input.installationId, + eventType: input.eventType, + version: input.version, + previousVersion, + ipAddress, + userAgent: input.userAgent ?? null, + os: input.os ?? null, + osVersion: input.osVersion ?? null, + architecture: input.architecture ?? null, + dockerVersion: input.dockerVersion ?? null, + composeVersion: input.composeVersion ?? null, + status: EntityStatus.ACTIVE, + }); + + const saved = await this.selfHostInstallationRepository.save(event); + + return { + message: PUBLIC_MESSAGES.INSTALLATION.EVENT_RECORDED, + data: { id: saved.id }, + }; + } catch (error) { + this.logger.error( + `Failed to record installation event for '${input.installationId}': ${toErrorMessage(error)}`, + ); + + throw new InternalServerErrorException( + "Unable to record the installation event. Please try again later.", + ); + } + } +} diff --git a/install.sh b/install.sh index 5a67ce32..b532874a 100755 --- a/install.sh +++ b/install.sh @@ -20,9 +20,23 @@ # KUBEARA_CONSOLE_IMAGE_OVERRIDE Explicit console image (do not export KUBEARA_CONSOLE_IMAGE — same name as .env key) # KUBEARA_CONTROL_PANEL_IMAGE / KUBEARA_AGENT_IMAGE Override API/agent images # KUBEARA_PUBLIC_URL Public panel URL for remote agents (optional; console API defaults to localhost) -# ENCRYPTION_SECRET Pre-set secret (default: auto-generate) -# SKIP_MIGRATE=1 Skip database migrations + seed -# KUBEARA_FORCE_ENV=1 Regenerate .env.control-panel from example +# KUBEARA_TRACKING_URL Override installation tracking endpoint +# Default: https://api.kubeara.dev/api/public/installations/events +# +# Installation lifecycle tracking (POST …/api/public/installations/events): +# INSTALL — no .installation-id yet (or id exists but .version never saved) +# UPGRADE — .installation-id exists and .version differs from the version +# in the repository's package.json +# (skip) — same version already reported in .version +# UNINSTALL — handled by uninstall.sh; clears .installation-id + .version +# +# Version files (under KUBEARA_INSTALL_DIR): +# .installation-id Stable UUID for this host install (kept across upgrades) +# .version Last version successfully reported (INSTALL or UPGRADE) +# +# ENCRYPTION_SECRET Pre-set secret (default: auto-generate) +# SKIP_MIGRATE=1 Skip database migrations + seed +# KUBEARA_FORCE_ENV=1 Regenerate .env.control-panel from example set -euo pipefail @@ -30,6 +44,9 @@ readonly LOG_PREFIX="[kubeara-install]" readonly COMPOSE_FILE="docker-compose.control-panel.yml" readonly ENV_FILE=".env.control-panel" readonly ENV_EXAMPLE=".env.control-panel.example" +readonly INSTALLATION_ID_FILE=".installation-id" +readonly INSTALLATION_VERSION_FILE=".version" +readonly DEFAULT_TRACKING_URL="https://api.kubeara.dev/api/public/installations/events" KUBEARA_REPO="${KUBEARA_REPO:-kubeara/core}" KUBEARA_VERSION="${KUBEARA_VERSION:-main}" KUBEARA_CHANNEL="${KUBEARA_CHANNEL:-prod}" @@ -234,13 +251,16 @@ services: env_file: - .env.control-panel environment: - NODE_ENV: production + # Self-hosted compose Postgres has no TLS. Default development so current + # Hub images (which enable SSL when NODE_ENV=production) can connect. + NODE_ENV: ${NODE_ENV:-development} DOCKER_ENV: "true" DB_HOST: ${DB_HOST:-postgres} DB_PORT: 5432 DB_USERNAME: ${DB_USERNAME:-postgres} DB_PASSWORD: ${DB_PASSWORD:-postgres} DB_DATABASE: ${DB_DATABASE:-kubeara} + DB_SSL: ${DB_SSL:-false} ENCRYPTION_SECRET: ${ENCRYPTION_SECRET:?Set ENCRYPTION_SECRET in .env.control-panel} volumes: - ./.env.control-panel:/app/apps/control-panel-app/.env:ro @@ -265,7 +285,7 @@ services: env_file: - .env.control-panel environment: - NODE_ENV: production + NODE_ENV: ${NODE_ENV:-development} DOCKER_ENV: "true" PORT: ${PORT:-3000} DB_HOST: ${DB_HOST:-postgres} @@ -273,13 +293,25 @@ services: DB_USERNAME: ${DB_USERNAME:-postgres} DB_PASSWORD: ${DB_PASSWORD:-postgres} DB_DATABASE: ${DB_DATABASE:-kubeara} + DB_SSL: ${DB_SSL:-false} ENCRYPTION_SECRET: ${ENCRYPTION_SECRET:?Set ENCRYPTION_SECRET in .env.control-panel} JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env.control-panel} JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?Set JWT_REFRESH_SECRET in .env.control-panel} + ACCESS_TOKEN_COOKIE_NAME: ${ACCESS_TOKEN_COOKIE_NAME:-kubeara_access_token} + REFRESH_TOKEN_COOKIE_NAME: ${REFRESH_TOKEN_COOKIE_NAME:-kubeara_refresh_token} ACCESS_TOKEN_EXPIRES_IN: ${ACCESS_TOKEN_EXPIRES_IN:-15m} REFRESH_TOKEN_EXPIRES_IN: ${REFRESH_TOKEN_EXPIRES_IN:-7d} - CONTROL_PANEL_URL: ${CONTROL_PANEL_URL:-} + COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} + COOKIE_SECURE: ${COOKIE_SECURE:-false} + COOKIE_SAME_SITE: ${COOKIE_SAME_SITE:-lax} + CONTROL_PANEL_URL: ${CONTROL_PANEL_URL:-http://localhost:3000} KUBEARA_AGENT_IMAGE: ${KUBEARA_AGENT_IMAGE:-kubeara/agent:prod} + GRAFANA_CLOUD_LOKI_URL: ${GRAFANA_CLOUD_LOKI_URL:-} + GRAFANA_CLOUD_LOKI_USER: ${GRAFANA_CLOUD_LOKI_USER:-} + GRAFANA_CLOUD_LOKI_API_KEY: ${GRAFANA_CLOUD_LOKI_API_KEY:-} + KUBEARA_ENV: ${KUBEARA_ENV:-} + KUBEARA_HOST_LABEL: ${KUBEARA_HOST_LABEL:-control-panel} + LOG_LEVEL: ${LOG_LEVEL:-info} volumes: - ./.env.control-panel:/app/apps/control-panel-app/.env:ro depends_on: @@ -332,6 +364,7 @@ write_fresh_env_file() { local secret platform vite_api_url control_panel_url port console_port local jwt_access jwt_refresh local cp_image console_image agent_image + local cors_origins secret="$(generate_encryption_secret)" platform="${DOCKER_PLATFORM:-$(detect_docker_platform)}" @@ -344,27 +377,54 @@ write_fresh_env_file() { cp_image="${KUBEARA_CONTROL_PANEL_IMAGE:-kubeara/control-panel:${KUBEARA_CHANNEL}}" console_image="${KUBEARA_CONSOLE_IMAGE_OVERRIDE:-kubeara/console:${KUBEARA_CHANNEL}}" agent_image="${KUBEARA_AGENT_IMAGE:-kubeara/agent:${KUBEARA_CHANNEL}}" + cors_origins="${CORS_ALLOWED_ORIGINS:-http://localhost:${port},http://localhost:${console_port},http://127.0.0.1:${port},http://127.0.0.1:${console_port}}" cat >"${env_path}" </dev/null | head -n1 | cut -d= -f2- || echo 3000)" + console_port="$(grep -E '^[[:space:]]*CONSOLE_PORT=' "${env_path}" 2>/dev/null | head -n1 | cut -d= -f2- || echo 8080)" + cors_origins="http://localhost:${port},http://localhost:${console_port},http://127.0.0.1:${port},http://127.0.0.1:${console_port}" current="$(grep -E '^[[:space:]]*JWT_SECRET=' "${env_path}" 2>/dev/null | head -n1 | cut -d= -f2- || true)" if [[ -z "${current}" || "${current}" == change-me-jwt-secret ]]; then @@ -443,6 +510,66 @@ ensure_jwt_config() { if ! grep -qE '^[[:space:]]*REFRESH_TOKEN_EXPIRES_IN=' "${env_path}"; then set_env_var "REFRESH_TOKEN_EXPIRES_IN" "${REFRESH_TOKEN_EXPIRES_IN:-7d}" "${env_path}" fi + + if ! grep -qE '^[[:space:]]*ACCESS_TOKEN_COOKIE_NAME=' "${env_path}"; then + set_env_var "ACCESS_TOKEN_COOKIE_NAME" \ + "${ACCESS_TOKEN_COOKIE_NAME:-kubeara_access_token}" \ + "${env_path}" + fi + + if ! grep -qE '^[[:space:]]*REFRESH_TOKEN_COOKIE_NAME=' "${env_path}"; then + set_env_var "REFRESH_TOKEN_COOKIE_NAME" \ + "${REFRESH_TOKEN_COOKIE_NAME:-kubeara_refresh_token}" \ + "${env_path}" + fi + + if ! grep -qE '^[[:space:]]*COOKIE_DOMAIN=' "${env_path}"; then + set_env_var "COOKIE_DOMAIN" "${COOKIE_DOMAIN:-}" "${env_path}" + fi + + if ! grep -qE '^[[:space:]]*COOKIE_SECURE=' "${env_path}"; then + set_env_var "COOKIE_SECURE" "${COOKIE_SECURE:-false}" "${env_path}" + fi + + if ! grep -qE '^[[:space:]]*COOKIE_SAME_SITE=' "${env_path}"; then + set_env_var "COOKIE_SAME_SITE" "${COOKIE_SAME_SITE:-lax}" "${env_path}" + fi + + if ! grep -qE '^[[:space:]]*NODE_ENV=' "${env_path}"; then + set_env_var "NODE_ENV" "${NODE_ENV:-development}" "${env_path}" + fi + + if ! grep -qE '^[[:space:]]*OTP_EXPIRES_IN=' "${env_path}"; then + set_env_var "OTP_EXPIRES_IN" "${OTP_EXPIRES_IN:-2m}" "${env_path}" + fi + + if ! grep -qE '^[[:space:]]*PUBLIC_API_ALLOWED_ORIGINS=' "${env_path}"; then + set_env_var "PUBLIC_API_ALLOWED_ORIGINS" \ + "${PUBLIC_API_ALLOWED_ORIGINS:-${cors_origins}}" \ + "${env_path}" + fi + + if ! grep -qE '^[[:space:]]*CORS_ALLOWED_ORIGINS=' "${env_path}"; then + set_env_var "CORS_ALLOWED_ORIGINS" \ + "${CORS_ALLOWED_ORIGINS:-${cors_origins}}" \ + "${env_path}" + fi + + if ! grep -qE '^[[:space:]]*DB_SSL=' "${env_path}"; then + set_env_var "DB_SSL" "${DB_SSL:-false}" "${env_path}" + fi + + # Older published images call getOrThrow('BREVO_*') at startup. Ensure the keys + # exist (even empty) so self-host boots until those images are replaced. + if ! grep -qE '^[[:space:]]*BREVO_API_KEY=' "${env_path}"; then + set_env_var "BREVO_API_KEY" "${BREVO_API_KEY:-}" "${env_path}" + fi + if ! grep -qE '^[[:space:]]*BREVO_FROM_EMAIL=' "${env_path}"; then + set_env_var "BREVO_FROM_EMAIL" "${BREVO_FROM_EMAIL:-}" "${env_path}" + fi + if ! grep -qE '^[[:space:]]*BREVO_FROM_NAME=' "${env_path}"; then + set_env_var "BREVO_FROM_NAME" "${BREVO_FROM_NAME:-Kubeara}" "${env_path}" + fi } compose() { @@ -471,6 +598,268 @@ wait_for_control_panel() { warn "Control panel did not respond on http://127.0.0.1:${port}/api/health within 2 minutes. Check: docker compose -f ${COMPOSE_FILE} logs" } +# --- Installation tracking (best-effort; never fails the installer) --- + +generate_uuid() { + local hex + if command -v uuidgen >/dev/null 2>&1; then + uuidgen | tr '[:upper:]' '[:lower:]' + return 0 + fi + if command -v openssl >/dev/null 2>&1; then + # RFC 4122 version-4 UUID from 16 random bytes. + hex="$(openssl rand -hex 16)" + printf '%s-%s-4%s-%s%s-%s\n' \ + "${hex:0:8}" \ + "${hex:8:4}" \ + "${hex:13:3}" \ + "$(printf '%x' "$((0x${hex:16:1} & 0x3 | 0x8))")" \ + "${hex:17:3}" \ + "${hex:20:12}" + return 0 + fi + return 1 +} + +get_or_create_installation_id() { + local id_file="${KUBEARA_INSTALL_DIR}/${INSTALLATION_ID_FILE}" + local id + + if [[ -f "${id_file}" ]]; then + id="$(tr -d '[:space:]' <"${id_file}" || true)" + if [[ -n "${id}" ]]; then + printf '%s\n' "${id}" + return 0 + fi + fi + + if ! id="$(generate_uuid)"; then + warn "Could not generate installation ID." + return 1 + fi + printf '%s\n' "${id}" >"${id_file}" + printf '%s\n' "${id}" +} + +get_previous_version() { + local version_file="${KUBEARA_INSTALL_DIR}/${INSTALLATION_VERSION_FILE}" + if [[ ! -f "${version_file}" ]]; then + return 0 + fi + tr -d '[:space:]' <"${version_file}" || true +} + +# Resolve the Release Please-managed version from the public repository. +# If GitHub is unavailable, fall back to the configured image tag/channel. +get_current_version() { + local package_url repository_version last_reported_version image tag + + package_url="https://raw.githubusercontent.com/${KUBEARA_REPO}/${KUBEARA_VERSION}/package.json" + repository_version="$( + curl -fsSL \ + --connect-timeout 5 \ + --max-time 10 \ + "${package_url}" 2>/dev/null | + sed -nE 's/^[[:space:]]*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*$/\1/p' | + head -n1 || true + )" + if [[ -n "${repository_version}" ]]; then + printf '%s\n' "${repository_version}" + return 0 + fi + + # Avoid a false UPGRADE (for example, 0.0.15 → prod) during a temporary + # GitHub outage. A fresh offline install still falls back to the image tag. + last_reported_version="$(get_previous_version || true)" + if [[ -n "${last_reported_version}" ]]; then + printf '%s\n' "${last_reported_version}" + return 0 + fi + + image="$(grep -E '^[[:space:]]*KUBEARA_CONTROL_PANEL_IMAGE=' "${KUBEARA_INSTALL_DIR}/${ENV_FILE}" 2>/dev/null | head -n1 | cut -d= -f2- || true)" + if [[ -n "${image}" && "${image}" == *:* ]]; then + tag="${image##*:}" + tag="${tag%%@*}" + if [[ -n "${tag}" ]]; then + printf '%s\n' "${tag}" + return 0 + fi + fi + + printf '%s\n' "${KUBEARA_CHANNEL:-prod}" +} + +# Prints INSTALL, UPGRADE, or empty (skip — same version already reported). +# had_installation_id: 1 if .installation-id already existed before this run. +# previous_version comes from .version, which is written only after a successful +# tracking POST — so a failed report is retried on the next installer run. +detect_installation_event() { + local current_version="$1" + local previous_version="$2" + local had_installation_id="$3" + + if [[ "${had_installation_id}" != "1" ]]; then + printf '%s\n' "INSTALL" + return 0 + fi + + # Never successfully reported (no .version) — retry as INSTALL. + if [[ -z "${previous_version}" ]]; then + printf '%s\n' "INSTALL" + return 0 + fi + + if [[ "${previous_version}" == "${current_version}" ]]; then + printf '%s\n' "" + return 0 + fi + + printf '%s\n' "UPGRADE" +} + +get_os_info() { + local name="" + if [[ -r /etc/os-release ]]; then + # shellcheck disable=SC1091 + name="$(. /etc/os-release && printf '%s' "${NAME:-${ID:-}}")" + fi + printf '%s\n' "${name}" +} + +get_os_version() { + local version="" + if [[ -r /etc/os-release ]]; then + # shellcheck disable=SC1091 + version="$(. /etc/os-release && printf '%s' "${VERSION_ID:-}")" + fi + printf '%s\n' "${version}" +} + +get_architecture() { + case "$(uname -m 2>/dev/null || true)" in + x86_64 | amd64) printf '%s\n' "amd64" ;; + aarch64 | arm64) printf '%s\n' "arm64" ;; + *) uname -m 2>/dev/null || true ;; + esac +} + +get_docker_version() { + docker version --format '{{.Server.Version}}' 2>/dev/null || true +} + +# Handles both "5.1.4" (--short) and "Docker Compose version v5.1.4". +get_compose_version() { + local raw + raw="$(docker compose version --short 2>/dev/null || docker compose version 2>/dev/null || true)" + raw="$(printf '%s' "${raw}" | head -n1 | sed -E 's/^[^0-9]*v?([0-9]+(\.[0-9]+)*).*$/\1/' || true)" + printf '%s\n' "${raw}" +} + +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/'"$(printf '\t')"'/\\t/g' | tr -d '\n\r' +} + +# Append ,"key":"value" when value is non-empty; no-op otherwise. +json_append_string_field() { + local key="$1" + local value="$2" + if [[ -z "${value}" ]]; then + return 0 + fi + printf ',"%s":"%s"' "${key}" "$(json_escape "${value}")" +} + +save_installation_version() { + local version="$1" + local version_file="${KUBEARA_INSTALL_DIR}/${INSTALLATION_VERSION_FILE}" + printf '%s\n' "${version}" >"${version_file}" +} + +# Posts to DEFAULT_TRACKING_URL unless KUBEARA_TRACKING_URL is set. +resolve_tracking_url() { + printf '%s\n' "${KUBEARA_TRACKING_URL:-${DEFAULT_TRACKING_URL}}" +} + +track_installation_event() { + local installation_id="$1" + local event_type="$2" + local version="$3" + local previous_version="${4:-}" + local tracking_url payload previous_json + local os_name os_version architecture docker_version compose_version + local http_code response_file + + if [[ -z "${installation_id}" || -z "${event_type}" || -z "${version}" ]]; then + return 0 + fi + + tracking_url="$(resolve_tracking_url)" + + os_name="$(get_os_info || true)" + os_version="$(get_os_version || true)" + architecture="$(get_architecture || true)" + docker_version="$(get_docker_version || true)" + compose_version="$(get_compose_version || true)" + + if [[ "${event_type}" == "UPGRADE" ]]; then + if [[ -z "${previous_version}" ]]; then + previous_version="unknown" + fi + previous_json="\"$(json_escape "${previous_version}")\"" + else + previous_json="null" + fi + + payload="$( + printf '{"installationId":"%s","eventType":"%s","version":"%s","previousVersion":%s,"userAgent":"%s"' \ + "$(json_escape "${installation_id}")" \ + "$(json_escape "${event_type}")" \ + "$(json_escape "${version}")" \ + "${previous_json}" \ + "$(json_escape "kubeara-install.sh")" + json_append_string_field "os" "${os_name}" + json_append_string_field "osVersion" "${os_version}" + json_append_string_field "architecture" "${architecture}" + json_append_string_field "dockerVersion" "${docker_version}" + json_append_string_field "composeVersion" "${compose_version}" + printf '}' + )" + + response_file="$(mktemp)" + info "Reporting ${event_type} event to ${tracking_url}…" + if [[ "${event_type}" == "UPGRADE" ]]; then + info "Tracking payload: id=${installation_id} ${previous_version} → ${version} os=${os_name:-?} arch=${architecture:-?} docker=${docker_version:-?}" + else + info "Tracking payload: id=${installation_id} version=${version} os=${os_name:-?} arch=${architecture:-?} docker=${docker_version:-?}" + fi + http_code="$( + curl -sS \ + --connect-timeout 10 \ + --max-time 30 \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "${payload}" \ + -o "${response_file}" \ + -w '%{http_code}' \ + "${tracking_url}" \ + 2>/dev/null || printf '000' + )" + + if [[ "${http_code}" =~ ^2 ]]; then + info "Installation event recorded (HTTP ${http_code})." + rm -f "${response_file}" + return 0 + fi + + warn "Could not report installation event (HTTP ${http_code}). Will retry on next install." + if [[ -s "${response_file}" ]]; then + warn "Tracking response: $(tr -d '\n' <"${response_file}" | head -c 300)" + fi + rm -f "${response_file}" + return 1 +} + print_success() { local port console_port port="$(grep -E '^PORT=' "${KUBEARA_INSTALL_DIR}/${ENV_FILE}" | cut -d= -f2- || echo 3000)" @@ -488,6 +877,12 @@ EOF } main() { + local had_installation_id=0 + local installation_id="" + local previous_version="" + local current_version="" + local event_type="" + detect_local_deploy_dir || true default_install_dir require_docker @@ -495,6 +890,24 @@ main() { materialize_deploy_files create_env_file + if [[ -f "${KUBEARA_INSTALL_DIR}/${INSTALLATION_ID_FILE}" ]]; then + had_installation_id=1 + fi + installation_id="$(get_or_create_installation_id || true)" + previous_version="$(get_previous_version || true)" + current_version="$(get_current_version || true)" + if [[ -n "${installation_id}" && -n "${current_version}" ]]; then + event_type="$(detect_installation_event "${current_version}" "${previous_version}" "${had_installation_id}" || true)" + fi + + if [[ "${event_type}" == "INSTALL" ]]; then + info "Lifecycle: INSTALL (version ${current_version})" + elif [[ "${event_type}" == "UPGRADE" ]]; then + info "Lifecycle: UPGRADE (${previous_version:-unknown} → ${current_version})" + elif [[ -n "${current_version}" ]]; then + info "Lifecycle: no event (already on ${current_version})" + fi + info "Console image: $(grep -E '^KUBEARA_CONSOLE_IMAGE=' "${KUBEARA_INSTALL_DIR}/${ENV_FILE}" | cut -d= -f2-)" info "Control panel image: $(grep -E '^KUBEARA_CONTROL_PANEL_IMAGE=' "${KUBEARA_INSTALL_DIR}/${ENV_FILE}" | cut -d= -f2- || echo "(compose default)")" info "Pulling images…" @@ -505,6 +918,23 @@ main() { run_migrate wait_for_control_panel + + if [[ -n "${event_type}" && -n "${installation_id}" && -n "${current_version}" ]]; then + if track_installation_event \ + "${installation_id}" \ + "${event_type}" \ + "${current_version}" \ + "${previous_version}"; then + save_installation_version "${current_version}" || true + else + warn "Installation tracking failed; it will be retried on the next installer run." + fi + elif [[ -n "${installation_id}" && -n "${current_version}" ]]; then + info "Skipping installation tracking (already reported version ${current_version})." + else + warn "Skipping installation tracking (missing installation id or version)." + fi + print_success } diff --git a/uninstall.sh b/uninstall.sh index df1ef704..52e2a5fb 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -6,19 +6,32 @@ # curl -fsSL https://kubeara.dev/control-panel/uninstall.sh | bash # # Environment: -# KUBEARA_INSTALL_DIR Same directory used by install.sh +# KUBEARA_INSTALL_DIR Same directory used by install.sh # KUBEARA_REMOVE_VOLUMES=1 Also delete Postgres data (docker compose down -v) +# KUBEARA_TRACKING_URL Override installation tracking endpoint +# +# Lifecycle tracking: +# Reports UNINSTALL with the last successfully tracked version from .version. +# Clears .installation-id and .version afterward so the next install.sh run +# is a fresh INSTALL with a new installation UUID. set -euo pipefail readonly LOG_PREFIX="[kubeara-uninstall]" readonly COMPOSE_FILE="docker-compose.control-panel.yml" readonly ENV_FILE=".env.control-panel" +readonly INSTALLATION_ID_FILE=".installation-id" +readonly INSTALLATION_VERSION_FILE=".version" +readonly DEFAULT_TRACKING_URL="https://api.kubeara.dev/api/public/installations/events" info() { echo "${LOG_PREFIX} $*" } +warn() { + echo "${LOG_PREFIX} WARNING: $*" >&2 +} + error() { echo "${LOG_PREFIX} ERROR: $*" >&2 exit 1 @@ -35,7 +48,171 @@ default_install_dir() { fi } +read_installation_id() { + local id_file="${KUBEARA_INSTALL_DIR}/${INSTALLATION_ID_FILE}" + if [[ ! -f "${id_file}" ]]; then + return 0 + fi + tr -d '[:space:]' <"${id_file}" || true +} + +read_installation_version() { + local version_file="${KUBEARA_INSTALL_DIR}/${INSTALLATION_VERSION_FILE}" + if [[ ! -f "${version_file}" ]]; then + return 0 + fi + tr -d '[:space:]' <"${version_file}" || true +} + +# Fallback when .version is missing: image tag from env, else channel, else unknown. +resolve_uninstall_version() { + local version image tag + version="$(read_installation_version || true)" + if [[ -n "${version}" ]]; then + printf '%s\n' "${version}" + return 0 + fi + + image="$(grep -E '^[[:space:]]*KUBEARA_CONTROL_PANEL_IMAGE=' "${KUBEARA_INSTALL_DIR}/${ENV_FILE}" 2>/dev/null | head -n1 | cut -d= -f2- || true)" + if [[ -n "${image}" && "${image}" == *:* ]]; then + tag="${image##*:}" + tag="${tag%%@*}" + if [[ -n "${tag}" ]]; then + printf '%s\n' "${tag}" + return 0 + fi + fi + + printf '%s\n' "${KUBEARA_CHANNEL:-unknown}" +} + +get_os_info() { + local name="" + if [[ -r /etc/os-release ]]; then + # shellcheck disable=SC1091 + name="$(. /etc/os-release && printf '%s' "${NAME:-${ID:-}}")" + fi + printf '%s\n' "${name}" +} + +get_os_version() { + local version="" + if [[ -r /etc/os-release ]]; then + # shellcheck disable=SC1091 + version="$(. /etc/os-release && printf '%s' "${VERSION_ID:-}")" + fi + printf '%s\n' "${version}" +} + +get_architecture() { + case "$(uname -m 2>/dev/null || true)" in + x86_64 | amd64) printf '%s\n' "amd64" ;; + aarch64 | arm64) printf '%s\n' "arm64" ;; + *) uname -m 2>/dev/null || true ;; + esac +} + +get_docker_version() { + docker version --format '{{.Server.Version}}' 2>/dev/null || true +} + +# Handles both "5.1.4" (--short) and "Docker Compose version v5.1.4". +get_compose_version() { + local raw + raw="$(docker compose version --short 2>/dev/null || docker compose version 2>/dev/null || true)" + raw="$(printf '%s' "${raw}" | head -n1 | sed -E 's/^[^0-9]*v?([0-9]+(\.[0-9]+)*).*$/\1/' || true)" + printf '%s\n' "${raw}" +} + +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/'"$(printf '\t')"'/\\t/g' | tr -d '\n\r' +} + +json_append_string_field() { + local key="$1" + local value="$2" + if [[ -z "${value}" ]]; then + return 0 + fi + printf ',"%s":"%s"' "${key}" "$(json_escape "${value}")" +} + +resolve_tracking_url() { + printf '%s\n' "${KUBEARA_TRACKING_URL:-${DEFAULT_TRACKING_URL}}" +} + +# Best-effort UNINSTALL report. Does not fail the uninstaller. +track_uninstall_event() { + local installation_id="$1" + local version="$2" + local tracking_url payload + local os_name os_version architecture docker_version compose_version + local http_code response_file + + if [[ -z "${installation_id}" || -z "${version}" ]]; then + warn "Skipping UNINSTALL tracking (missing installation id or version)." + return 0 + fi + + tracking_url="$(resolve_tracking_url)" + os_name="$(get_os_info || true)" + os_version="$(get_os_version || true)" + architecture="$(get_architecture || true)" + docker_version="$(get_docker_version || true)" + compose_version="$(get_compose_version || true)" + + payload="$( + printf '{"installationId":"%s","eventType":"UNINSTALL","version":"%s","previousVersion":null,"userAgent":"%s"' \ + "$(json_escape "${installation_id}")" \ + "$(json_escape "${version}")" \ + "$(json_escape "kubeara-uninstall.sh")" + json_append_string_field "os" "${os_name}" + json_append_string_field "osVersion" "${os_version}" + json_append_string_field "architecture" "${architecture}" + json_append_string_field "dockerVersion" "${docker_version}" + json_append_string_field "composeVersion" "${compose_version}" + printf '}' + )" + + response_file="$(mktemp)" + info "Reporting UNINSTALL event (version ${version}) to ${tracking_url}…" + info "Tracking payload: id=${installation_id} version=${version} os=${os_name:-?} arch=${architecture:-?} docker=${docker_version:-?}" + http_code="$( + curl -sS \ + --connect-timeout 10 \ + --max-time 30 \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "${payload}" \ + -o "${response_file}" \ + -w '%{http_code}' \ + "${tracking_url}" \ + 2>/dev/null || printf '000' + )" + + if [[ "${http_code}" =~ ^2 ]]; then + info "Uninstall event recorded (HTTP ${http_code})." + else + warn "Could not report uninstall event (HTTP ${http_code}). Continuing uninstall." + if [[ -s "${response_file}" ]]; then + warn "Tracking response: $(tr -d '\n' <"${response_file}" | head -c 300)" + fi + fi + rm -f "${response_file}" +} + +clear_installation_tracking_state() { + rm -f \ + "${KUBEARA_INSTALL_DIR}/${INSTALLATION_ID_FILE}" \ + "${KUBEARA_INSTALL_DIR}/${INSTALLATION_VERSION_FILE}" + info "Cleared ${INSTALLATION_ID_FILE} and ${INSTALLATION_VERSION_FILE} (next install will be a fresh INSTALL)." +} + main() { + local installation_id="" + local version="" + default_install_dir if [[ ! -d "${KUBEARA_INSTALL_DIR}" ]]; then @@ -52,6 +229,10 @@ main() { cd "${KUBEARA_INSTALL_DIR}" + # Capture tracking identity before tearing the stack down. + installation_id="$(read_installation_id || true)" + version="$(resolve_uninstall_version || true)" + local down_args=(down) if [[ "${KUBEARA_REMOVE_VOLUMES:-}" == "1" ]]; then down_args+=( -v ) @@ -66,6 +247,9 @@ main() { docker compose -f "${COMPOSE_FILE}" "${down_args[@]}" fi + track_uninstall_event "${installation_id}" "${version}" || true + clear_installation_tracking_state + info "Done. Install files remain in ${KUBEARA_INSTALL_DIR}" } From 5633b40ad3fd502092e859e1944a92e90363ed9d Mon Sep 17 00:00:00 2001 From: dhaval-p-iqud Date: Wed, 5 Aug 2026 16:13:07 +0530 Subject: [PATCH 2/4] refactor: Improve uninstallation script and database SSL configuration - Enhanced uninstall.sh to validate installation directory before removal and ensure safe operations. - Updated Docker Compose commands to remove all associated resources during uninstallation. - Simplified SSL configuration handling in various files by removing unnecessary environment variable dependencies. - Added error messages for better clarity in email service and public installation processes. --- .../config/typeorm.config.ts | 2 +- .../deploy/docker-compose.control-panel.yml | 2 +- .../seeders/billing-cycles.seed.ts | 5 +- apps/control-panel-app/seeders/plans.seed.ts | 5 +- .../seeders/templates.seed.ts | 5 +- apps/control-panel-app/src/app.module.ts | 5 +- .../src/common/utils/client-ip.util.ts | 25 +++ .../src/constants/env.constant.ts | 17 +- .../src/modules/email/email.constants.ts | 4 + .../src/modules/email/email.service.ts | 6 +- .../constants/public-messages.constants.ts | 2 + .../public-installations.controller.ts | 24 +-- .../self-host-installation.service.ts | 2 +- scripts/dev-install-tunnel-proxy.py | 191 ++++++++++++++++++ uninstall.sh | 67 ++++-- 15 files changed, 283 insertions(+), 79 deletions(-) create mode 100644 apps/control-panel-app/src/common/utils/client-ip.util.ts create mode 100755 scripts/dev-install-tunnel-proxy.py diff --git a/apps/control-panel-app/config/typeorm.config.ts b/apps/control-panel-app/config/typeorm.config.ts index 0d4b3424..b458f747 100644 --- a/apps/control-panel-app/config/typeorm.config.ts +++ b/apps/control-panel-app/config/typeorm.config.ts @@ -21,7 +21,7 @@ export default new DataSource({ password: getRequiredEnv("DB_PASSWORD"), database: getRequiredEnv("DB_DATABASE"), synchronize: false, - ...(isDbSslEnabled(process.env.DB_SSL, process.env.NODE_ENV) + ...(isDbSslEnabled(process.env.DB_SSL) ? { ssl: { rejectUnauthorized: false } } : {}), entities: [ diff --git a/apps/control-panel-app/deploy/docker-compose.control-panel.yml b/apps/control-panel-app/deploy/docker-compose.control-panel.yml index db36bf77..bc9d1034 100644 --- a/apps/control-panel-app/deploy/docker-compose.control-panel.yml +++ b/apps/control-panel-app/deploy/docker-compose.control-panel.yml @@ -70,7 +70,7 @@ services: env_file: - .env.control-panel environment: - NODE_ENV: ${NODE_ENV:-development} + NODE_ENV: ${NODE_ENV:-production} DOCKER_ENV: "true" PORT: ${PORT:-3000} DB_HOST: ${DB_HOST:-postgres} diff --git a/apps/control-panel-app/seeders/billing-cycles.seed.ts b/apps/control-panel-app/seeders/billing-cycles.seed.ts index 92ca5f9f..95c28739 100644 --- a/apps/control-panel-app/seeders/billing-cycles.seed.ts +++ b/apps/control-panel-app/seeders/billing-cycles.seed.ts @@ -53,10 +53,7 @@ function loadEnv(): ConfigService { export async function seedBillingCycles(): Promise { const configService = loadEnv(); - const useSsl = isDbSslEnabled( - configService.get("DB_SSL"), - configService.get("NODE_ENV"), - ); + const useSsl = isDbSslEnabled(configService.get("DB_SSL")); const ds = new DataSource({ type: "postgres", host: configService.get("DB_HOST"), diff --git a/apps/control-panel-app/seeders/plans.seed.ts b/apps/control-panel-app/seeders/plans.seed.ts index 8295b6d0..3b3153e8 100644 --- a/apps/control-panel-app/seeders/plans.seed.ts +++ b/apps/control-panel-app/seeders/plans.seed.ts @@ -26,10 +26,7 @@ const LEGACY_SLUGS = ["starter", "pro", "max", "business"] as const; export async function seedPlans(): Promise { const configService = loadEnv(); - const useSsl = isDbSslEnabled( - configService.get("DB_SSL"), - configService.get("NODE_ENV"), - ); + const useSsl = isDbSslEnabled(configService.get("DB_SSL")); const ds = new DataSource({ type: "postgres", host: configService.get("DB_HOST"), diff --git a/apps/control-panel-app/seeders/templates.seed.ts b/apps/control-panel-app/seeders/templates.seed.ts index 15547ec2..29352483 100644 --- a/apps/control-panel-app/seeders/templates.seed.ts +++ b/apps/control-panel-app/seeders/templates.seed.ts @@ -157,10 +157,7 @@ function validateRequiredConfig(configService: ConfigService): void { */ function createDataSource(configService: ConfigService): DataSource { try { - const useSsl = isDbSslEnabled( - configService.get("DB_SSL"), - configService.get("NODE_ENV"), - ); + const useSsl = isDbSslEnabled(configService.get("DB_SSL")); return new DataSource({ type: "postgres", host: configService.get("DB_HOST") as string, diff --git a/apps/control-panel-app/src/app.module.ts b/apps/control-panel-app/src/app.module.ts index 4557366e..2c81a3a4 100644 --- a/apps/control-panel-app/src/app.module.ts +++ b/apps/control-panel-app/src/app.module.ts @@ -36,10 +36,7 @@ import { PublicModule } from "./modules/public/public.module"; inject: [ConfigService], useFactory: (configService: ConfigService) => { try { - const useSsl = isDbSslEnabled( - configService.get("DB_SSL"), - configService.get("NODE_ENV"), - ); + const useSsl = isDbSslEnabled(configService.get("DB_SSL")); return { type: "postgres", diff --git a/apps/control-panel-app/src/common/utils/client-ip.util.ts b/apps/control-panel-app/src/common/utils/client-ip.util.ts new file mode 100644 index 00000000..5ac1ded1 --- /dev/null +++ b/apps/control-panel-app/src/common/utils/client-ip.util.ts @@ -0,0 +1,25 @@ +import type { Request } from "express"; + +/** + * Derive the client IP from the HTTP request. + * Prefers the first X-Forwarded-For hop; does not trust body-supplied values. + */ +export function resolveClientIp(request: Request): string { + const forwarded = request.headers["x-forwarded-for"]; + + if (typeof forwarded === "string" && forwarded.trim().length > 0) { + const firstHop = forwarded.split(",")[0]?.trim(); + if (firstHop) { + return firstHop; + } + } + + if (Array.isArray(forwarded) && forwarded[0]) { + const firstHop = forwarded[0].split(",")[0]?.trim(); + if (firstHop) { + return firstHop; + } + } + + return request.ip || request.socket.remoteAddress || "unknown"; +} diff --git a/apps/control-panel-app/src/constants/env.constant.ts b/apps/control-panel-app/src/constants/env.constant.ts index e33f5d2f..5d426ad8 100644 --- a/apps/control-panel-app/src/constants/env.constant.ts +++ b/apps/control-panel-app/src/constants/env.constant.ts @@ -9,21 +9,10 @@ export function isProductionEnv(nodeEnv: string | undefined): boolean { /** * Enable Postgres SSL only when DB_SSL=true. - * Self-hosted compose sets DB_SSL=false so local Postgres works even if - * NODE_ENV=production. When DB_SSL is unset, keep legacy production behavior. + * Local/self-host examples set DB_SSL=false; production sets DB_SSL=true. */ -export function isDbSslEnabled( - dbSsl: string | undefined | null, - nodeEnv?: string | null, -): boolean { - const normalized = dbSsl?.trim().toLowerCase(); - if (normalized === "true") { - return true; - } - if (normalized === "false") { - return false; - } - return isProductionEnv(nodeEnv ?? undefined); +export function isDbSslEnabled(dbSsl: string | undefined | null): boolean { + return dbSsl?.trim().toLowerCase() === "true"; } export const SALT_ROUNDS = 10; diff --git a/apps/control-panel-app/src/modules/email/email.constants.ts b/apps/control-panel-app/src/modules/email/email.constants.ts index bf38183a..7052d9d1 100644 --- a/apps/control-panel-app/src/modules/email/email.constants.ts +++ b/apps/control-panel-app/src/modules/email/email.constants.ts @@ -10,3 +10,7 @@ export const OTP_EMAIL_COPY = { FOOTER_SUFFIX: "Please do not reply to this email.", GREETING_FALLBACK: "Hi there,", } as const; + +export const EMAIL_ERROR_MESSAGES = { + NOT_CONFIGURED: "Email service is not configured.", +} as const; diff --git a/apps/control-panel-app/src/modules/email/email.service.ts b/apps/control-panel-app/src/modules/email/email.service.ts index 6968acd0..73b10fa5 100644 --- a/apps/control-panel-app/src/modules/email/email.service.ts +++ b/apps/control-panel-app/src/modules/email/email.service.ts @@ -1,7 +1,7 @@ import { Injectable, ServiceUnavailableException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { BrevoClient } from "@getbrevo/brevo"; -import { OTP_EMAIL_COPY } from "./email.constants"; +import { OTP_EMAIL_COPY, EMAIL_ERROR_MESSAGES } from "./email.constants"; import { escapeHtml, formatOtp, @@ -56,7 +56,9 @@ export class EmailService { purposeLabel: string; }): Promise { if (!this.brevo || !this.fromEmail) { - throw new ServiceUnavailableException("Email service is not configured."); + throw new ServiceUnavailableException( + EMAIL_ERROR_MESSAGES.NOT_CONFIGURED, + ); } const subject = `Your ${input.purposeLabel} code`; diff --git a/apps/control-panel-app/src/modules/public/constants/public-messages.constants.ts b/apps/control-panel-app/src/modules/public/constants/public-messages.constants.ts index 45baf9ef..490f5b84 100644 --- a/apps/control-panel-app/src/modules/public/constants/public-messages.constants.ts +++ b/apps/control-panel-app/src/modules/public/constants/public-messages.constants.ts @@ -7,5 +7,7 @@ export const PUBLIC_MESSAGES = { }, INSTALLATION: { EVENT_RECORDED: "Installation event recorded successfully.", + EVENT_RECORD_FAILED: + "Unable to record the installation event. Please try again later.", }, } as const; diff --git a/apps/control-panel-app/src/modules/public/controllers/public-installations.controller.ts b/apps/control-panel-app/src/modules/public/controllers/public-installations.controller.ts index 8d303de3..866d0cf6 100644 --- a/apps/control-panel-app/src/modules/public/controllers/public-installations.controller.ts +++ b/apps/control-panel-app/src/modules/public/controllers/public-installations.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Logger, Post, Req } from "@nestjs/common"; import type { Request } from "express"; import { ServiceResponse } from "@control-panel/common/interfaces/success-response.interface"; +import { resolveClientIp } from "@control-panel/common/utils/client-ip.util"; import { toErrorMessage } from "@control-panel/common/utils/error.util"; import { RecordInstallationEventDto } from "../dto/record-installation-event.dto"; @@ -37,26 +38,3 @@ export class InstallationsController { } } } - -/** - * Derive the client IP from the HTTP request. Does not trust body-supplied values. - */ -function resolveClientIp(request: Request): string { - const forwarded = request.headers["x-forwarded-for"]; - - if (typeof forwarded === "string" && forwarded.trim().length > 0) { - const firstHop = forwarded.split(",")[0]?.trim(); - if (firstHop) { - return firstHop; - } - } - - if (Array.isArray(forwarded) && forwarded[0]) { - const firstHop = forwarded[0].split(",")[0]?.trim(); - if (firstHop) { - return firstHop; - } - } - - return request.ip || request.socket.remoteAddress || "unknown"; -} diff --git a/apps/control-panel-app/src/modules/public/services/self-host-installation.service.ts b/apps/control-panel-app/src/modules/public/services/self-host-installation.service.ts index 7c4399d3..5b3975d0 100644 --- a/apps/control-panel-app/src/modules/public/services/self-host-installation.service.ts +++ b/apps/control-panel-app/src/modules/public/services/self-host-installation.service.ts @@ -67,7 +67,7 @@ export class SelfHostInstallationService { ); throw new InternalServerErrorException( - "Unable to record the installation event. Please try again later.", + PUBLIC_MESSAGES.INSTALLATION.EVENT_RECORD_FAILED, ); } } diff --git a/scripts/dev-install-tunnel-proxy.py b/scripts/dev-install-tunnel-proxy.py new file mode 100755 index 00000000..9c7c98be --- /dev/null +++ b/scripts/dev-install-tunnel-proxy.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +""" +Dev helper: serve install.sh (and other repo files) while proxying /api/* to Nest. + +Use this instead of `python3 -m http.server 8000` when testing remote installs +against a local control-panel on :3000 through a single ngrok tunnel: + + # Terminal A — Nest on :3000 + # Terminal B + python3 scripts/dev-install-tunnel-proxy.py + # Terminal C + ngrok http 8000 + +Remote: + export KUBEARA_TRACKING_URL=https:///api/public/installations/events + curl -fsSL https:///install.sh | bash + +Tracking POSTs to /api/public/installations/events are forwarded to Nest. +""" + +from __future__ import annotations + +import argparse +import http.server +import os +import socketserver +import sys +import urllib.error +import urllib.request + +DEFAULT_PORT = 8000 +DEFAULT_API_ORIGIN = "http://127.0.0.1:3000" +HOP_BY_HOP = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "host", + "content-length", +} + + +class DualPurposeHandler(http.server.SimpleHTTPRequestHandler): + api_origin: str = DEFAULT_API_ORIGIN + + def do_GET(self) -> None: # noqa: N802 + if self.path.startswith("/api/") or self.path == "/api": + self._proxy_to_api() + return + super().do_GET() + + def do_HEAD(self) -> None: # noqa: N802 + if self.path.startswith("/api/") or self.path == "/api": + self._proxy_to_api() + return + super().do_HEAD() + + def do_POST(self) -> None: # noqa: N802 + if self.path.startswith("/api/") or self.path == "/api": + self._proxy_to_api() + return + self.send_error(501, "Unsupported method ('POST')") + + def do_PUT(self) -> None: # noqa: N802 + if self.path.startswith("/api/") or self.path == "/api": + self._proxy_to_api() + return + self.send_error(501, "Unsupported method ('PUT')") + + def do_PATCH(self) -> None: # noqa: N802 + if self.path.startswith("/api/") or self.path == "/api": + self._proxy_to_api() + return + self.send_error(501, "Unsupported method ('PATCH')") + + def do_DELETE(self) -> None: # noqa: N802 + if self.path.startswith("/api/") or self.path == "/api": + self._proxy_to_api() + return + self.send_error(501, "Unsupported method ('DELETE')") + + def _proxy_to_api(self) -> None: + length = int(self.headers.get("Content-Length", "0") or "0") + body = self.rfile.read(length) if length > 0 else None + target = f"{self.api_origin.rstrip('/')}{self.path}" + + headers = { + key: value + for key, value in self.headers.items() + if key.lower() not in HOP_BY_HOP + } + headers.setdefault("ngrok-skip-browser-warning", "true") + + request = urllib.request.Request( + target, + data=body, + headers=headers, + method=self.command, + ) + + try: + with urllib.request.urlopen(request, timeout=30) as response: + payload = response.read() + self.send_response(response.status) + for key, value in response.headers.items(): + if key.lower() in HOP_BY_HOP: + continue + self.send_header(key, value) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(payload) + except urllib.error.HTTPError as error: + payload = error.read() + self.send_response(error.code) + for key, value in error.headers.items(): + if key.lower() in HOP_BY_HOP: + continue + self.send_header(key, value) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(payload) + except Exception as error: # noqa: BLE001 + message = f"Proxy to Nest failed: {error}\n".encode() + self.send_response(502) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(message))) + self.end_headers() + if self.command != "HEAD": + self.wfile.write(message) + + def log_message(self, format: str, *args) -> None: # noqa: A003 + sys.stderr.write( + "%s - - [%s] %s\n" + % (self.address_string(), self.log_date_time_string(), format % args) + ) + + +class ReusableTCPServer(socketserver.TCPServer): + allow_reuse_address = True + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument( + "--api-origin", + default=os.environ.get("KUBEARA_DEV_API_ORIGIN", DEFAULT_API_ORIGIN), + help="Nest origin to proxy /api/* to (default: http://127.0.0.1:3000)", + ) + parser.add_argument( + "--root", + default=os.environ.get( + "KUBEARA_DEV_STATIC_ROOT", + os.path.abspath(os.path.join(os.path.dirname(__file__), "..")), + ), + help="Directory to serve statically (default: repo root)", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + os.chdir(args.root) + + handler = DualPurposeHandler + handler.api_origin = args.api_origin + + with ReusableTCPServer(("0.0.0.0", args.port), handler) as httpd: + print( + f"Serving {args.root} on :{args.port}; proxying /api/* -> {args.api_origin}", + flush=True, + ) + print( + "Keep ngrok pointed at this port. Stop plain `python3 -m http.server`.", + flush=True, + ) + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nStopped.", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uninstall.sh b/uninstall.sh index 52e2a5fb..db756cfe 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -7,13 +7,15 @@ # # Environment: # KUBEARA_INSTALL_DIR Same directory used by install.sh -# KUBEARA_REMOVE_VOLUMES=1 Also delete Postgres data (docker compose down -v) # KUBEARA_TRACKING_URL Override installation tracking endpoint # +# Removal: +# Deletes the complete Kubeara stack: containers, networks, volumes, +# service images, installation files, and the empty Kubeara parent directory. +# # Lifecycle tracking: # Reports UNINSTALL with the last successfully tracked version from .version. -# Clears .installation-id and .version afterward so the next install.sh run -# is a fresh INSTALL with a new installation UUID. +# A later install.sh run is a fresh INSTALL with a new installation UUID. set -euo pipefail @@ -202,11 +204,34 @@ track_uninstall_event() { rm -f "${response_file}" } -clear_installation_tracking_state() { - rm -f \ - "${KUBEARA_INSTALL_DIR}/${INSTALLATION_ID_FILE}" \ - "${KUBEARA_INSTALL_DIR}/${INSTALLATION_VERSION_FILE}" - info "Cleared ${INSTALLATION_ID_FILE} and ${INSTALLATION_VERSION_FILE} (next install will be a fresh INSTALL)." +validate_install_dir_for_removal() { + local resolved_dir + + resolved_dir="$(cd "${KUBEARA_INSTALL_DIR}" && pwd -P)" + case "${resolved_dir}" in + "" | "/" | "/opt" | "/opt/kubeara" | "${HOME}" | "${HOME}/.kubeara") + error "Refusing to remove unsafe install directory: ${resolved_dir}" + ;; + esac + + KUBEARA_INSTALL_DIR="${resolved_dir}" +} + +remove_install_directory() { + local parent_dir + + parent_dir="$(dirname "${KUBEARA_INSTALL_DIR}")" + cd / + rm -rf -- "${KUBEARA_INSTALL_DIR}" + info "Removed installation directory ${KUBEARA_INSTALL_DIR}" + + case "${parent_dir}" in + "/opt/kubeara" | "${HOME}/.kubeara") + if rmdir -- "${parent_dir}" 2>/dev/null; then + info "Removed empty directory ${parent_dir}" + fi + ;; + esac } main() { @@ -223,6 +248,8 @@ main() { error "Compose file not found in ${KUBEARA_INSTALL_DIR}" fi + validate_install_dir_for_removal + if ! command -v docker >/dev/null 2>&1; then error "Docker is not installed" fi @@ -233,24 +260,22 @@ main() { installation_id="$(read_installation_id || true)" version="$(resolve_uninstall_version || true)" - local down_args=(down) - if [[ "${KUBEARA_REMOVE_VOLUMES:-}" == "1" ]]; then - down_args+=( -v ) - info "Stopping stack and removing volumes (database data will be deleted)…" - else - info "Stopping stack (data volumes kept). Set KUBEARA_REMOVE_VOLUMES=1 to delete DB data." - fi + track_uninstall_event "${installation_id}" "${version}" || true + info "Removing containers, networks, volumes, and service images…" if [[ -f "${ENV_FILE}" ]]; then - docker compose -f "${COMPOSE_FILE}" --env-file "${ENV_FILE}" "${down_args[@]}" + docker compose \ + -f "${COMPOSE_FILE}" \ + --env-file "${ENV_FILE}" \ + down --volumes --rmi all --remove-orphans else - docker compose -f "${COMPOSE_FILE}" "${down_args[@]}" + docker compose \ + -f "${COMPOSE_FILE}" \ + down --volumes --rmi all --remove-orphans fi - track_uninstall_event "${installation_id}" "${version}" || true - clear_installation_tracking_state - - info "Done. Install files remain in ${KUBEARA_INSTALL_DIR}" + remove_install_directory + info "Kubeara was completely removed." } main "$@" From 11bba45ccec2db86e204580b055a3d9252b7ab50 Mon Sep 17 00:00:00 2001 From: dhaval-p-iqud Date: Wed, 5 Aug 2026 16:25:38 +0530 Subject: [PATCH 3/4] chore: Remove dev-install-tunnel-proxy script --- scripts/dev-install-tunnel-proxy.py | 191 ---------------------------- 1 file changed, 191 deletions(-) delete mode 100755 scripts/dev-install-tunnel-proxy.py diff --git a/scripts/dev-install-tunnel-proxy.py b/scripts/dev-install-tunnel-proxy.py deleted file mode 100755 index 9c7c98be..00000000 --- a/scripts/dev-install-tunnel-proxy.py +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env python3 -""" -Dev helper: serve install.sh (and other repo files) while proxying /api/* to Nest. - -Use this instead of `python3 -m http.server 8000` when testing remote installs -against a local control-panel on :3000 through a single ngrok tunnel: - - # Terminal A — Nest on :3000 - # Terminal B - python3 scripts/dev-install-tunnel-proxy.py - # Terminal C - ngrok http 8000 - -Remote: - export KUBEARA_TRACKING_URL=https:///api/public/installations/events - curl -fsSL https:///install.sh | bash - -Tracking POSTs to /api/public/installations/events are forwarded to Nest. -""" - -from __future__ import annotations - -import argparse -import http.server -import os -import socketserver -import sys -import urllib.error -import urllib.request - -DEFAULT_PORT = 8000 -DEFAULT_API_ORIGIN = "http://127.0.0.1:3000" -HOP_BY_HOP = { - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailers", - "transfer-encoding", - "upgrade", - "host", - "content-length", -} - - -class DualPurposeHandler(http.server.SimpleHTTPRequestHandler): - api_origin: str = DEFAULT_API_ORIGIN - - def do_GET(self) -> None: # noqa: N802 - if self.path.startswith("/api/") or self.path == "/api": - self._proxy_to_api() - return - super().do_GET() - - def do_HEAD(self) -> None: # noqa: N802 - if self.path.startswith("/api/") or self.path == "/api": - self._proxy_to_api() - return - super().do_HEAD() - - def do_POST(self) -> None: # noqa: N802 - if self.path.startswith("/api/") or self.path == "/api": - self._proxy_to_api() - return - self.send_error(501, "Unsupported method ('POST')") - - def do_PUT(self) -> None: # noqa: N802 - if self.path.startswith("/api/") or self.path == "/api": - self._proxy_to_api() - return - self.send_error(501, "Unsupported method ('PUT')") - - def do_PATCH(self) -> None: # noqa: N802 - if self.path.startswith("/api/") or self.path == "/api": - self._proxy_to_api() - return - self.send_error(501, "Unsupported method ('PATCH')") - - def do_DELETE(self) -> None: # noqa: N802 - if self.path.startswith("/api/") or self.path == "/api": - self._proxy_to_api() - return - self.send_error(501, "Unsupported method ('DELETE')") - - def _proxy_to_api(self) -> None: - length = int(self.headers.get("Content-Length", "0") or "0") - body = self.rfile.read(length) if length > 0 else None - target = f"{self.api_origin.rstrip('/')}{self.path}" - - headers = { - key: value - for key, value in self.headers.items() - if key.lower() not in HOP_BY_HOP - } - headers.setdefault("ngrok-skip-browser-warning", "true") - - request = urllib.request.Request( - target, - data=body, - headers=headers, - method=self.command, - ) - - try: - with urllib.request.urlopen(request, timeout=30) as response: - payload = response.read() - self.send_response(response.status) - for key, value in response.headers.items(): - if key.lower() in HOP_BY_HOP: - continue - self.send_header(key, value) - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - if self.command != "HEAD": - self.wfile.write(payload) - except urllib.error.HTTPError as error: - payload = error.read() - self.send_response(error.code) - for key, value in error.headers.items(): - if key.lower() in HOP_BY_HOP: - continue - self.send_header(key, value) - self.send_header("Content-Length", str(len(payload))) - self.end_headers() - if self.command != "HEAD": - self.wfile.write(payload) - except Exception as error: # noqa: BLE001 - message = f"Proxy to Nest failed: {error}\n".encode() - self.send_response(502) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.send_header("Content-Length", str(len(message))) - self.end_headers() - if self.command != "HEAD": - self.wfile.write(message) - - def log_message(self, format: str, *args) -> None: # noqa: A003 - sys.stderr.write( - "%s - - [%s] %s\n" - % (self.address_string(), self.log_date_time_string(), format % args) - ) - - -class ReusableTCPServer(socketserver.TCPServer): - allow_reuse_address = True - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--port", type=int, default=DEFAULT_PORT) - parser.add_argument( - "--api-origin", - default=os.environ.get("KUBEARA_DEV_API_ORIGIN", DEFAULT_API_ORIGIN), - help="Nest origin to proxy /api/* to (default: http://127.0.0.1:3000)", - ) - parser.add_argument( - "--root", - default=os.environ.get( - "KUBEARA_DEV_STATIC_ROOT", - os.path.abspath(os.path.join(os.path.dirname(__file__), "..")), - ), - help="Directory to serve statically (default: repo root)", - ) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - os.chdir(args.root) - - handler = DualPurposeHandler - handler.api_origin = args.api_origin - - with ReusableTCPServer(("0.0.0.0", args.port), handler) as httpd: - print( - f"Serving {args.root} on :{args.port}; proxying /api/* -> {args.api_origin}", - flush=True, - ) - print( - "Keep ngrok pointed at this port. Stop plain `python3 -m http.server`.", - flush=True, - ) - try: - httpd.serve_forever() - except KeyboardInterrupt: - print("\nStopped.", flush=True) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From 64fc15f84cd690b1c603707d82cbaa2c5a8a588e Mon Sep 17 00:00:00 2001 From: dhaval-p-iqud Date: Wed, 5 Aug 2026 16:36:31 +0530 Subject: [PATCH 4/4] fix: Update database SSL configuration to use getRequiredEnv for environment variable retrieval --- apps/control-panel-app/config/typeorm.config.ts | 2 +- apps/control-panel-app/deploy/docker-compose.control-panel.yml | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/control-panel-app/config/typeorm.config.ts b/apps/control-panel-app/config/typeorm.config.ts index b458f747..a66c31ee 100644 --- a/apps/control-panel-app/config/typeorm.config.ts +++ b/apps/control-panel-app/config/typeorm.config.ts @@ -21,7 +21,7 @@ export default new DataSource({ password: getRequiredEnv("DB_PASSWORD"), database: getRequiredEnv("DB_DATABASE"), synchronize: false, - ...(isDbSslEnabled(process.env.DB_SSL) + ...(isDbSslEnabled(getRequiredEnv("DB_SSL")) ? { ssl: { rejectUnauthorized: false } } : {}), entities: [ diff --git a/apps/control-panel-app/deploy/docker-compose.control-panel.yml b/apps/control-panel-app/deploy/docker-compose.control-panel.yml index bc9d1034..a7456e9b 100644 --- a/apps/control-panel-app/deploy/docker-compose.control-panel.yml +++ b/apps/control-panel-app/deploy/docker-compose.control-panel.yml @@ -37,8 +37,7 @@ services: env_file: - .env.control-panel environment: - # development: Hub images enable SSL when NODE_ENV=production; compose Postgres has no TLS. - NODE_ENV: ${NODE_ENV:-development} + NODE_ENV: ${NODE_ENV:-production} DOCKER_ENV: "true" DB_HOST: ${DB_HOST:-postgres} DB_PORT: 5432