diff --git a/.gitignore b/.gitignore index 467dd2c..71f1751 100644 --- a/.gitignore +++ b/.gitignore @@ -46,4 +46,5 @@ landing/* .pnpm-store/ .seeded *.md -/docs/* \ No newline at end of file +/docs/* +test-* diff --git a/README.md b/README.md index 0964549..9abccd9 100644 --- a/README.md +++ b/README.md @@ -601,7 +601,7 @@ curl -X POST http://localhost:3001/api/auth/login \ ## 📄 License -This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. +This project is licensed under the **License** - see the [LICENSE](LICENSE) file for details. ## 👥 Support & Community diff --git a/apps/api/prisma/migrations/20251014043307_add_domain_advanced_settings/migration.sql b/apps/api/prisma/migrations/20251014043307_add_domain_advanced_settings/migration.sql new file mode 100644 index 0000000..82b0e96 --- /dev/null +++ b/apps/api/prisma/migrations/20251014043307_add_domain_advanced_settings/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "domains" ADD COLUMN "customLocations" JSONB, +ADD COLUMN "grpcEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "hstsEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "http2Enabled" BOOLEAN NOT NULL DEFAULT true; diff --git a/apps/api/prisma/migrations/20251014102338_add_access_lists_management/migration.sql b/apps/api/prisma/migrations/20251014102338_add_access_lists_management/migration.sql new file mode 100644 index 0000000..1ade394 --- /dev/null +++ b/apps/api/prisma/migrations/20251014102338_add_access_lists_management/migration.sql @@ -0,0 +1,74 @@ +-- CreateEnum +CREATE TYPE "AccessListType" AS ENUM ('ip_whitelist', 'http_basic_auth', 'combined'); + +-- CreateTable +CREATE TABLE "access_lists" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "type" "AccessListType" NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "allowedIps" TEXT[] DEFAULT ARRAY[]::TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "access_lists_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "access_list_auth_users" ( + "id" TEXT NOT NULL, + "accessListId" TEXT NOT NULL, + "username" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "description" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "access_list_auth_users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "access_list_domains" ( + "id" TEXT NOT NULL, + "accessListId" TEXT NOT NULL, + "domainId" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "access_list_domains_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "access_lists_name_key" ON "access_lists"("name"); + +-- CreateIndex +CREATE INDEX "access_lists_type_idx" ON "access_lists"("type"); + +-- CreateIndex +CREATE INDEX "access_lists_enabled_idx" ON "access_lists"("enabled"); + +-- CreateIndex +CREATE INDEX "access_list_auth_users_accessListId_idx" ON "access_list_auth_users"("accessListId"); + +-- CreateIndex +CREATE UNIQUE INDEX "access_list_auth_users_accessListId_username_key" ON "access_list_auth_users"("accessListId", "username"); + +-- CreateIndex +CREATE INDEX "access_list_domains_accessListId_idx" ON "access_list_domains"("accessListId"); + +-- CreateIndex +CREATE INDEX "access_list_domains_domainId_idx" ON "access_list_domains"("domainId"); + +-- CreateIndex +CREATE UNIQUE INDEX "access_list_domains_accessListId_domainId_key" ON "access_list_domains"("accessListId", "domainId"); + +-- AddForeignKey +ALTER TABLE "access_list_auth_users" ADD CONSTRAINT "access_list_auth_users_accessListId_fkey" FOREIGN KEY ("accessListId") REFERENCES "access_lists"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "access_list_domains" ADD CONSTRAINT "access_list_domains_accessListId_fkey" FOREIGN KEY ("accessListId") REFERENCES "access_lists"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "access_list_domains" ADD CONSTRAINT "access_list_domains_domainId_fkey" FOREIGN KEY ("domainId") REFERENCES "domains"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 80c3de0..598942d 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -183,12 +183,19 @@ model Domain { realIpCloudflare Boolean @default(false) // Use Cloudflare IP ranges realIpCustomCidrs String[] @default([]) // Custom CIDR ranges for set_real_ip_from + // Advanced Configuration + hstsEnabled Boolean @default(false) // HTTP Strict Transport Security + http2Enabled Boolean @default(true) // Enable HTTP/2 + grpcEnabled Boolean @default(false) // Enable gRPC/gRPCs support + customLocations Json? // Custom location blocks configuration + // Relations upstreams Upstream[] loadBalancer LoadBalancerConfig? sslCertificate SSLCertificate? modsecCRSRules ModSecCRSRule[] modsecRules ModSecRule[] + accessLists AccessListDomain[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -454,6 +461,73 @@ model AclRule { @@map("acl_rules") } +// Access Lists Management Models + +enum AccessListType { + ip_whitelist + http_basic_auth + combined // Both IP and Basic Auth +} + +model AccessList { + id String @id @default(cuid()) + name String @unique + description String? @db.Text + type AccessListType + enabled Boolean @default(true) + + // IP Whitelist configuration + allowedIps String[] @default([]) // List of allowed IP addresses/CIDR + + // HTTP Basic Auth configuration + authUsers AccessListAuthUser[] + + // Relations to domains + domains AccessListDomain[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([type]) + @@index([enabled]) + @@map("access_lists") +} + +model AccessListAuthUser { + id String @id @default(cuid()) + accessListId String + accessList AccessList @relation(fields: [accessListId], references: [id], onDelete: Cascade) + + username String + passwordHash String // Plain text password - will be hashed by htpasswd tool with apr1/MD5 format + description String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([accessListId, username]) + @@index([accessListId]) + @@map("access_list_auth_users") +} + +model AccessListDomain { + id String @id @default(cuid()) + accessListId String + accessList AccessList @relation(fields: [accessListId], references: [id], onDelete: Cascade) + domainId String + domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade) + + enabled Boolean @default(true) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([accessListId, domainId]) + @@index([accessListId]) + @@index([domainId]) + @@map("access_list_domains") +} + model PerformanceMetric { id String @id @default(cuid()) domain String diff --git a/apps/api/src/domains/access-lists/access-lists.controller.ts b/apps/api/src/domains/access-lists/access-lists.controller.ts new file mode 100644 index 0000000..5e2317f --- /dev/null +++ b/apps/api/src/domains/access-lists/access-lists.controller.ts @@ -0,0 +1,366 @@ +import { Request, Response } from 'express'; +import { validationResult } from 'express-validator'; +import { accessListsService } from './access-lists.service'; +import logger from '../../utils/logger'; +import { AccessListQueryOptions } from './access-lists.types'; + +/** + * Controller for Access Lists endpoints + */ +export class AccessListsController { + /** + * Get all access lists + */ + async getAccessLists(req: Request, res: Response) { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: errors.array(), + }); + } + + const options: AccessListQueryOptions = { + page: parseInt(req.query.page as string) || 1, + limit: parseInt(req.query.limit as string) || 10, + search: req.query.search as string, + type: req.query.type as any, + enabled: req.query.enabled === 'true' ? true : req.query.enabled === 'false' ? false : undefined, + }; + + const result = await accessListsService.getAccessLists(options); + + res.json({ + success: true, + data: result.accessLists, + pagination: result.pagination, + }); + } catch (error: any) { + logger.error('Failed to get access lists', error); + res.status(500).json({ + success: false, + message: error.message || 'Failed to get access lists', + }); + } + } + + /** + * Get single access list by ID + */ + async getAccessList(req: Request, res: Response) { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: errors.array(), + }); + } + + const { id } = req.params; + + const accessList = await accessListsService.getAccessListById(id); + + if (!accessList) { + return res.status(404).json({ + success: false, + message: 'Access list not found', + }); + } + + res.json({ + success: true, + data: accessList, + }); + } catch (error: any) { + logger.error('Failed to get access list', error); + res.status(500).json({ + success: false, + message: error.message || 'Failed to get access list', + }); + } + } + + /** + * Create new access list + */ + async createAccessList(req: Request, res: Response) { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: errors.array(), + }); + } + + const userId = (req as any).user.id; + const username = (req as any).user.username; + const ip = req.ip || req.socket.remoteAddress || ''; + const userAgent = req.headers['user-agent'] || ''; + + const accessList = await accessListsService.createAccessList( + req.body, + userId, + username, + ip, + userAgent + ); + + res.status(201).json({ + success: true, + message: 'Access list created successfully', + data: accessList, + }); + } catch (error: any) { + logger.error('Failed to create access list', error); + res.status(500).json({ + success: false, + message: error.message || 'Failed to create access list', + }); + } + } + + /** + * Update access list + */ + async updateAccessList(req: Request, res: Response) { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: errors.array(), + }); + } + + const { id } = req.params; + const userId = (req as any).user.id; + const username = (req as any).user.username; + const ip = req.ip || req.socket.remoteAddress || ''; + const userAgent = req.headers['user-agent'] || ''; + + const accessList = await accessListsService.updateAccessList( + id, + req.body, + userId, + username, + ip, + userAgent + ); + + res.json({ + success: true, + message: 'Access list updated successfully', + data: accessList, + }); + } catch (error: any) { + logger.error('Failed to update access list', error); + res.status(500).json({ + success: false, + message: error.message || 'Failed to update access list', + }); + } + } + + /** + * Delete access list + */ + async deleteAccessList(req: Request, res: Response) { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: errors.array(), + }); + } + + const { id } = req.params; + const userId = (req as any).user.id; + const username = (req as any).user.username; + const ip = req.ip || req.socket.remoteAddress || ''; + const userAgent = req.headers['user-agent'] || ''; + + await accessListsService.deleteAccessList(id, userId, username, ip, userAgent); + + res.json({ + success: true, + message: 'Access list deleted successfully', + }); + } catch (error: any) { + logger.error('Failed to delete access list', error); + res.status(500).json({ + success: false, + message: error.message || 'Failed to delete access list', + }); + } + } + + /** + * Toggle access list enabled status + */ + async toggleAccessList(req: Request, res: Response) { + try { + const { id } = req.params; + const { enabled } = req.body; + const userId = (req as any).user.id; + const username = (req as any).user.username; + const ip = req.ip || req.socket.remoteAddress || ''; + const userAgent = req.headers['user-agent'] || ''; + + const accessList = await accessListsService.toggleAccessList( + id, + enabled, + userId, + username, + ip, + userAgent + ); + + res.json({ + success: true, + message: `Access list ${enabled ? 'enabled' : 'disabled'} successfully`, + data: accessList, + }); + } catch (error: any) { + logger.error('Failed to toggle access list', error); + res.status(500).json({ + success: false, + message: error.message || 'Failed to toggle access list', + }); + } + } + + /** + * Apply access list to domain + */ + async applyToDomain(req: Request, res: Response) { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: errors.array(), + }); + } + + const userId = (req as any).user.id; + const username = (req as any).user.username; + const ip = req.ip || req.socket.remoteAddress || ''; + const userAgent = req.headers['user-agent'] || ''; + + const result = await accessListsService.applyToDomain( + req.body, + userId, + username, + ip, + userAgent + ); + + res.json({ + success: result.success, + message: result.message, + }); + } catch (error: any) { + logger.error('Failed to apply access list to domain', error); + res.status(500).json({ + success: false, + message: error.message || 'Failed to apply access list to domain', + }); + } + } + + /** + * Remove access list from domain + */ + async removeFromDomain(req: Request, res: Response) { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: 'Validation failed', + errors: errors.array(), + }); + } + + const { accessListId, domainId } = req.params; + const userId = (req as any).user.id; + const username = (req as any).user.username; + const ip = req.ip || req.socket.remoteAddress || ''; + const userAgent = req.headers['user-agent'] || ''; + + const result = await accessListsService.removeFromDomain( + accessListId, + domainId, + userId, + username, + ip, + userAgent + ); + + res.json({ + success: result.success, + message: result.message, + }); + } catch (error: any) { + logger.error('Failed to remove access list from domain', error); + res.status(500).json({ + success: false, + message: error.message || 'Failed to remove access list from domain', + }); + } + } + + /** + * Get access lists by domain + */ + async getByDomain(req: Request, res: Response) { + try { + const { domainId } = req.params; + + const accessLists = await accessListsService.getAccessListsByDomainId(domainId); + + res.json({ + success: true, + data: accessLists, + }); + } catch (error: any) { + logger.error('Failed to get access lists by domain', error); + res.status(500).json({ + success: false, + message: error.message || 'Failed to get access lists by domain', + }); + } + } + + /** + * Get statistics + */ + async getStats(req: Request, res: Response) { + try { + const stats = await accessListsService.getStats(); + + res.json({ + success: true, + data: stats, + }); + } catch (error: any) { + logger.error('Failed to get access lists stats', error); + res.status(500).json({ + success: false, + message: error.message || 'Failed to get access lists stats', + }); + } + } +} + +export const accessListsController = new AccessListsController(); diff --git a/apps/api/src/domains/access-lists/access-lists.repository.ts b/apps/api/src/domains/access-lists/access-lists.repository.ts new file mode 100644 index 0000000..cce047e --- /dev/null +++ b/apps/api/src/domains/access-lists/access-lists.repository.ts @@ -0,0 +1,384 @@ +import prisma from '../../config/database'; +import { + AccessListWithRelations, + AccessListQueryOptions, + CreateAccessListInput, + UpdateAccessListInput, +} from './access-lists.types'; +import { PaginationMeta } from '../../shared/types/common.types'; + +/** + * Repository for Access Lists data access + */ +export class AccessListsRepository { + /** + * Find all access lists with pagination and filters + */ + async findAll( + options: AccessListQueryOptions + ): Promise<{ accessLists: AccessListWithRelations[]; pagination: PaginationMeta }> { + const { + page = 1, + limit = 10, + search, + type, + enabled, + } = options; + + const skip = (page - 1) * limit; + + // Build where clause + const where: any = {}; + + if (search) { + where.OR = [ + { name: { contains: search, mode: 'insensitive' } }, + { description: { contains: search, mode: 'insensitive' } }, + ]; + } + + if (type) { + where.type = type; + } + + if (enabled !== undefined) { + where.enabled = enabled; + } + + // Get total count + const total = await prisma.accessList.count({ where }); + + // Get access lists + const accessLists = await prisma.accessList.findMany({ + where, + skip, + take: limit, + include: { + authUsers: true, + domains: { + include: { + domain: { + select: { + id: true, + name: true, + status: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return { + accessLists, + pagination: { + page, + limit, + totalCount: total, + totalPages: Math.ceil(total / limit), + hasNextPage: page < Math.ceil(total / limit), + hasPreviousPage: page > 1, + }, + }; + } + + /** + * Find access list by ID + */ + async findById(id: string): Promise { + return prisma.accessList.findUnique({ + where: { id }, + include: { + authUsers: true, + domains: { + include: { + domain: { + select: { + id: true, + name: true, + status: true, + }, + }, + }, + }, + }, + }); + } + + /** + * Find access list by name + */ + async findByName(name: string): Promise { + return prisma.accessList.findUnique({ + where: { name }, + include: { + authUsers: true, + domains: { + include: { + domain: { + select: { + id: true, + name: true, + status: true, + }, + }, + }, + }, + }, + }); + } + + /** + * Create new access list + */ + async create(data: CreateAccessListInput): Promise { + const { authUsers, domainIds, ...accessListData } = data; + + return prisma.accessList.create({ + data: { + ...accessListData, + authUsers: authUsers + ? { + create: authUsers.map((user) => ({ + username: user.username, + passwordHash: user.password, // Will be hashed in service + description: user.description, + })), + } + : undefined, + domains: domainIds + ? { + create: domainIds.map((domainId) => ({ + domainId, + enabled: true, + })), + } + : undefined, + }, + include: { + authUsers: true, + domains: { + include: { + domain: { + select: { + id: true, + name: true, + status: true, + }, + }, + }, + }, + }, + }); + } + + /** + * Update access list + */ + async update( + id: string, + data: UpdateAccessListInput + ): Promise { + const { authUsers, domainIds, ...accessListData } = data; + + // Start a transaction to handle updates + return prisma.$transaction(async (tx) => { + // Get existing auth users to preserve passwords if not changed + let existingAuthUsers: { username: string; passwordHash: string }[] = []; + if (authUsers !== undefined) { + existingAuthUsers = await tx.accessListAuthUser.findMany({ + where: { accessListId: id }, + select: { username: true, passwordHash: true }, + }); + } + + // Delete existing auth users if provided + if (authUsers !== undefined) { + await tx.accessListAuthUser.deleteMany({ + where: { accessListId: id }, + }); + } + + // Delete existing domain associations if provided + if (domainIds !== undefined) { + await tx.accessListDomain.deleteMany({ + where: { accessListId: id }, + }); + } + + // Update access list + return tx.accessList.update({ + where: { id }, + data: { + ...accessListData, + authUsers: authUsers + ? { + create: authUsers.map((user) => { + // If password is empty, try to preserve old password + let password = user.password; + if (!password || password.trim() === '') { + const existingUser = existingAuthUsers.find( + (u) => u.username === user.username + ); + password = existingUser?.passwordHash || ''; + } + + return { + username: user.username, + passwordHash: password, + description: user.description, + }; + }), + } + : undefined, + domains: domainIds + ? { + create: domainIds.map((domainId) => ({ + domainId, + enabled: true, + })), + } + : undefined, + }, + include: { + authUsers: true, + domains: { + include: { + domain: { + select: { + id: true, + name: true, + status: true, + }, + }, + }, + }, + }, + }); + }); + } + + /** + * Delete access list + */ + async delete(id: string): Promise { + await prisma.accessList.delete({ + where: { id }, + }); + } + + /** + * Toggle access list enabled status + */ + async toggleEnabled(id: string, enabled: boolean): Promise { + return prisma.accessList.update({ + where: { id }, + data: { enabled }, + include: { + authUsers: true, + domains: { + include: { + domain: { + select: { + id: true, + name: true, + status: true, + }, + }, + }, + }, + }, + }); + } + + /** + * Apply access list to domain + */ + async applyToDomain( + accessListId: string, + domainId: string, + enabled: boolean = true + ): Promise { + await prisma.accessListDomain.upsert({ + where: { + accessListId_domainId: { + accessListId, + domainId, + }, + }, + create: { + accessListId, + domainId, + enabled, + }, + update: { + enabled, + }, + }); + } + + /** + * Remove access list from domain + */ + async removeFromDomain(accessListId: string, domainId: string): Promise { + await prisma.accessListDomain.delete({ + where: { + accessListId_domainId: { + accessListId, + domainId, + }, + }, + }); + } + + /** + * Get access lists by domain ID + */ + async findByDomainId(domainId: string): Promise { + const accessListDomains = await prisma.accessListDomain.findMany({ + where: { domainId }, + include: { + accessList: { + include: { + authUsers: true, + domains: { + include: { + domain: { + select: { + id: true, + name: true, + status: true, + }, + }, + }, + }, + }, + }, + }, + }); + + return accessListDomains.map((ald) => ald.accessList); + } + + /** + * Get statistics + */ + async getStats() { + const totalAccessLists = await prisma.accessList.count(); + const enabledAccessLists = await prisma.accessList.count({ + where: { enabled: true }, + }); + const totalAuthUsers = await prisma.accessListAuthUser.count(); + const totalAssignedDomains = await prisma.accessListDomain.count(); + + return { + totalAccessLists, + enabledAccessLists, + totalAuthUsers, + totalAssignedDomains, + }; + } +} + +export const accessListsRepository = new AccessListsRepository(); diff --git a/apps/api/src/domains/access-lists/access-lists.routes.ts b/apps/api/src/domains/access-lists/access-lists.routes.ts new file mode 100644 index 0000000..0c639c9 --- /dev/null +++ b/apps/api/src/domains/access-lists/access-lists.routes.ts @@ -0,0 +1,132 @@ +import { Router, Request, Response } from 'express'; +import { accessListsController } from './access-lists.controller'; +import { authenticate, authorize } from '../../middleware/auth'; +import { + createAccessListValidation, + updateAccessListValidation, + getAccessListsValidation, + getAccessListValidation, + deleteAccessListValidation, + applyToDomainValidation, + removeFromDomainValidation, +} from './dto/access-lists.dto'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +/** + * @route GET /api/access-lists + * @desc Get all access lists + * @access Private (all roles) + */ +router.get( + '/', + getAccessListsValidation, + (req: Request, res: Response) => accessListsController.getAccessLists(req, res) +); + +/** + * @route GET /api/access-lists/stats + * @desc Get access lists statistics + * @access Private (all roles) + */ +router.get( + '/stats', + (req: Request, res: Response) => accessListsController.getStats(req, res) +); + +/** + * @route GET /api/access-lists/:id + * @desc Get single access list + * @access Private (all roles) + */ +router.get( + '/:id', + getAccessListValidation, + (req: Request, res: Response) => accessListsController.getAccessList(req, res) +); + +/** + * @route POST /api/access-lists + * @desc Create new access list + * @access Private (admin, moderator) + */ +router.post( + '/', + authorize('admin', 'moderator'), + createAccessListValidation, + (req: Request, res: Response) => accessListsController.createAccessList(req, res) +); + +/** + * @route PUT /api/access-lists/:id + * @desc Update access list + * @access Private (admin, moderator) + */ +router.put( + '/:id', + authorize('admin', 'moderator'), + updateAccessListValidation, + (req: Request, res: Response) => accessListsController.updateAccessList(req, res) +); + +/** + * @route DELETE /api/access-lists/:id + * @desc Delete access list + * @access Private (admin, moderator) + */ +router.delete( + '/:id', + authorize('admin', 'moderator'), + deleteAccessListValidation, + (req: Request, res: Response) => accessListsController.deleteAccessList(req, res) +); + +/** + * @route PATCH /api/access-lists/:id/toggle + * @desc Toggle access list enabled status + * @access Private (admin, moderator) + */ +router.patch( + '/:id/toggle', + authorize('admin', 'moderator'), + (req: Request, res: Response) => accessListsController.toggleAccessList(req, res) +); + +/** + * @route POST /api/access-lists/apply + * @desc Apply access list to domain + * @access Private (admin, moderator) + */ +router.post( + '/apply', + authorize('admin', 'moderator'), + applyToDomainValidation, + (req: Request, res: Response) => accessListsController.applyToDomain(req, res) +); + +/** + * @route DELETE /api/access-lists/:accessListId/domains/:domainId + * @desc Remove access list from domain + * @access Private (admin, moderator) + */ +router.delete( + '/:accessListId/domains/:domainId', + authorize('admin', 'moderator'), + removeFromDomainValidation, + (req: Request, res: Response) => accessListsController.removeFromDomain(req, res) +); + +/** + * @route GET /api/access-lists/domains/:domainId + * @desc Get access lists by domain + * @access Private (all roles) + */ +router.get( + '/domains/:domainId', + (req: Request, res: Response) => accessListsController.getByDomain(req, res) +); + +export default router; diff --git a/apps/api/src/domains/access-lists/access-lists.service.ts b/apps/api/src/domains/access-lists/access-lists.service.ts new file mode 100644 index 0000000..a97d4d7 --- /dev/null +++ b/apps/api/src/domains/access-lists/access-lists.service.ts @@ -0,0 +1,420 @@ +import logger from '../../utils/logger'; +import { accessListsRepository } from './access-lists.repository'; +import { nginxConfigService } from './services/nginx-config.service'; +import { domainsService } from '../domains/domains.service'; +import { + AccessListWithRelations, + AccessListQueryOptions, + CreateAccessListInput, + UpdateAccessListInput, + ApplyAccessListToDomainInput, + NginxConfigResult, +} from './access-lists.types'; +import { PaginationMeta } from '../../shared/types/common.types'; +import prisma from '../../config/database'; + +/** + * Main service for Access Lists operations + */ +export class AccessListsService { + /** + * Get all access lists with pagination and filters + */ + async getAccessLists( + options: AccessListQueryOptions + ): Promise<{ accessLists: AccessListWithRelations[]; pagination: PaginationMeta }> { + return accessListsRepository.findAll(options); + } + + /** + * Get access list by ID + */ + async getAccessListById(id: string): Promise { + return accessListsRepository.findById(id); + } + + /** + * Create new access list + */ + async createAccessList( + input: CreateAccessListInput, + userId: string, + username: string, + ip: string, + userAgent: string + ): Promise { + // Check if access list already exists + const existing = await accessListsRepository.findByName(input.name); + if (existing) { + throw new Error('Access list with this name already exists'); + } + + // Validate input based on type + this.validateAccessListInput(input); + + // Note: Passwords are stored as plain text because htpasswd tool will hash them + // during nginx config generation with the proper format (apr1/MD5) + + // Create access list + const accessList = await accessListsRepository.create(input); + + // Generate Nginx configuration + await nginxConfigService.generateConfig(accessList); + + // Reload Nginx if enabled + if (accessList.enabled) { + await nginxConfigService.reloadNginx(); + } + + // Log activity + await this.logActivity( + userId, + `Created access list: ${input.name}`, + 'config_change', + ip, + userAgent, + true + ); + + logger.info(`Access list ${input.name} created by user ${username}`); + + return accessList; + } + + /** + * Update access list + */ + async updateAccessList( + id: string, + input: UpdateAccessListInput, + userId: string, + username: string, + ip: string, + userAgent: string + ): Promise { + // Check if access list exists + const existing = await accessListsRepository.findById(id); + if (!existing) { + throw new Error('Access list not found'); + } + + // Check name uniqueness if changing + if (input.name && input.name !== existing.name) { + const duplicate = await accessListsRepository.findByName(input.name); + if (duplicate) { + throw new Error('Access list with this name already exists'); + } + } + + // Validate input based on type + if (input.type) { + this.validateAccessListInput(input as CreateAccessListInput); + } + + // Note: Passwords are stored as plain text because htpasswd tool will hash them + // during nginx config generation with the proper format (apr1/MD5) + + // Update access list + const accessList = await accessListsRepository.update(id, input); + + // Delete old config if name changed + if (input.name && input.name !== existing.name) { + await nginxConfigService.deleteConfig(existing.name); + } + + // Generate new Nginx configuration + await nginxConfigService.generateConfig(accessList); + + // Regenerate nginx configs for all domains using this access list + if (accessList.domains && accessList.domains.length > 0) { + for (const domainLink of accessList.domains) { + try { + await domainsService.regenerateConfig(domainLink.domainId); + logger.info(`Regenerated config for domain ${domainLink.domainId} after updating access list`); + } catch (error) { + logger.error(`Failed to regenerate config for domain ${domainLink.domainId}:`, error); + } + } + } + + // Always reload Nginx after update (config files changed) + await nginxConfigService.reloadNginx(); + + // Log activity + await this.logActivity( + userId, + `Updated access list: ${accessList.name}`, + 'config_change', + ip, + userAgent, + true + ); + + logger.info(`Access list ${accessList.name} updated by user ${username}`); + + return accessList; + } + + /** + * Delete access list + */ + async deleteAccessList( + id: string, + userId: string, + username: string, + ip: string, + userAgent: string + ): Promise { + // Check if access list exists + const accessList = await accessListsRepository.findById(id); + if (!accessList) { + throw new Error('Access list not found'); + } + + // Delete Nginx configuration + await nginxConfigService.deleteConfig(accessList.name); + + // Delete from database + await accessListsRepository.delete(id); + + // Reload Nginx + await nginxConfigService.reloadNginx(); + + // Log activity + await this.logActivity( + userId, + `Deleted access list: ${accessList.name}`, + 'config_change', + ip, + userAgent, + true + ); + + logger.info(`Access list ${accessList.name} deleted by user ${username}`); + } + + /** + * Toggle access list enabled status + */ + async toggleAccessList( + id: string, + enabled: boolean, + userId: string, + username: string, + ip: string, + userAgent: string + ): Promise { + // Check if access list exists + const existing = await accessListsRepository.findById(id); + if (!existing) { + throw new Error('Access list not found'); + } + + // Toggle enabled status + const accessList = await accessListsRepository.toggleEnabled(id, enabled); + + // Regenerate access list config file + await nginxConfigService.generateConfig(accessList); + + // Regenerate nginx configs for all domains using this access list + if (accessList.domains && accessList.domains.length > 0) { + for (const domainLink of accessList.domains) { + try { + await domainsService.regenerateConfig(domainLink.domainId); + logger.info(`Regenerated config for domain ${domainLink.domainId} after toggling access list`); + } catch (error) { + logger.error(`Failed to regenerate config for domain ${domainLink.domainId}:`, error); + } + } + } + + // Reload Nginx + await nginxConfigService.reloadNginx(); + + // Log activity + await this.logActivity( + userId, + `${enabled ? 'Enabled' : 'Disabled'} access list: ${accessList.name}`, + 'config_change', + ip, + userAgent, + true + ); + + logger.info(`Access list ${accessList.name} ${enabled ? 'enabled' : 'disabled'} by user ${username}`); + + return accessList; + } + + /** + * Apply access list to domain + */ + async applyToDomain( + input: ApplyAccessListToDomainInput, + userId: string, + username: string, + ip: string, + userAgent: string + ): Promise { + const { accessListId, domainId, enabled = true } = input; + + // Verify access list exists + const accessList = await accessListsRepository.findById(accessListId); + if (!accessList) { + throw new Error('Access list not found'); + } + + // Verify domain exists + const domain = await prisma.domain.findUnique({ + where: { id: domainId }, + }); + if (!domain) { + throw new Error('Domain not found'); + } + + // Apply to domain + await accessListsRepository.applyToDomain(accessListId, domainId, enabled); + + // Regenerate domain's nginx config to include access list + await domainsService.regenerateConfig(domainId); + + const reloadResult: NginxConfigResult = { success: true, message: 'Nginx reloaded successfully' }; + + // Log activity + await this.logActivity( + userId, + `Applied access list ${accessList.name} to domain ${domain.name}`, + 'config_change', + ip, + userAgent, + true + ); + + logger.info(`Access list ${accessList.name} applied to domain ${domain.name} by user ${username}`); + + return reloadResult; + } + + /** + * Remove access list from domain + */ + async removeFromDomain( + accessListId: string, + domainId: string, + userId: string, + username: string, + ip: string, + userAgent: string + ): Promise { + // Verify access list exists + const accessList = await accessListsRepository.findById(accessListId); + if (!accessList) { + throw new Error('Access list not found'); + } + + // Verify domain exists + const domain = await prisma.domain.findUnique({ + where: { id: domainId }, + }); + if (!domain) { + throw new Error('Domain not found'); + } + + // Remove from domain + await accessListsRepository.removeFromDomain(accessListId, domainId); + + // Regenerate domain's nginx config to remove access list + await domainsService.regenerateConfig(domainId); + + const reloadResult: NginxConfigResult = { success: true, message: 'Nginx reloaded successfully' }; + + // Log activity + await this.logActivity( + userId, + `Removed access list ${accessList.name} from domain ${domain.name}`, + 'config_change', + ip, + userAgent, + true + ); + + logger.info(`Access list ${accessList.name} removed from domain ${domain.name} by user ${username}`); + + return reloadResult; + } + + /** + * Get access lists by domain ID + */ + async getAccessListsByDomainId(domainId: string): Promise { + return accessListsRepository.findByDomainId(domainId); + } + + /** + * Get statistics + */ + async getStats() { + return accessListsRepository.getStats(); + } + + /** + * Validate access list input based on type + */ + private validateAccessListInput(input: CreateAccessListInput) { + switch (input.type) { + case 'ip_whitelist': + if (!input.allowedIps || input.allowedIps.length === 0) { + throw new Error('IP whitelist type requires at least one allowed IP'); + } + break; + + case 'http_basic_auth': + if (!input.authUsers || input.authUsers.length === 0) { + throw new Error('HTTP Basic Auth type requires at least one auth user'); + } + break; + + case 'combined': + if (!input.allowedIps || input.allowedIps.length === 0) { + throw new Error('Combined type requires at least one allowed IP'); + } + if (!input.authUsers || input.authUsers.length === 0) { + throw new Error('Combined type requires at least one auth user'); + } + break; + + default: + throw new Error(`Invalid access list type: ${input.type}`); + } + } + + /** + * Log activity + */ + private async logActivity( + userId: string, + action: string, + type: string, + ip: string, + userAgent: string, + success: boolean + ) { + try { + await prisma.activityLog.create({ + data: { + userId, + action, + type: type as any, + ip, + userAgent, + success, + timestamp: new Date(), + }, + }); + } catch (error) { + logger.error('Failed to log activity', error); + } + } +} + +export const accessListsService = new AccessListsService(); diff --git a/apps/api/src/domains/access-lists/access-lists.types.ts b/apps/api/src/domains/access-lists/access-lists.types.ts new file mode 100644 index 0000000..0a2a5d7 --- /dev/null +++ b/apps/api/src/domains/access-lists/access-lists.types.ts @@ -0,0 +1,108 @@ +import { AccessList, AccessListAuthUser, AccessListDomain } from '@prisma/client'; + +/** + * Access Lists domain types and enums + */ + +export enum AccessListType { + IP_WHITELIST = 'ip_whitelist', + HTTP_BASIC_AUTH = 'http_basic_auth', + COMBINED = 'combined' +} + +/** + * Access List with relations + */ +export type AccessListWithRelations = AccessList & { + authUsers?: AccessListAuthUser[]; + domains?: (AccessListDomain & { + domain: { + id: string; + name: string; + status: string; + }; + })[]; +}; + +/** + * Create Access List input + */ +export interface CreateAccessListInput { + name: string; + description?: string; + type: AccessListType; + enabled?: boolean; + allowedIps?: string[]; + authUsers?: CreateAuthUserInput[]; + domainIds?: string[]; +} + +/** + * Update Access List input + */ +export interface UpdateAccessListInput { + name?: string; + description?: string; + type?: AccessListType; + enabled?: boolean; + allowedIps?: string[]; + authUsers?: CreateAuthUserInput[]; + domainIds?: string[]; +} + +/** + * Create Auth User input + */ +export interface CreateAuthUserInput { + username: string; + password: string; + description?: string; +} + +/** + * Update Auth User input + */ +export interface UpdateAuthUserInput { + username?: string; + password?: string; + description?: string; +} + +/** + * Access List query options + */ +export interface AccessListQueryOptions { + page?: number; + limit?: number; + search?: string; + type?: AccessListType; + enabled?: boolean; +} + +/** + * Apply Access List to domain input + */ +export interface ApplyAccessListToDomainInput { + accessListId: string; + domainId: string; + enabled?: boolean; +} + +/** + * Nginx configuration result + */ +export interface NginxConfigResult { + success: boolean; + message: string; + configPath?: string; +} + +/** + * Access List statistics + */ +export interface AccessListStats { + totalAccessLists: number; + enabledAccessLists: number; + totalAuthUsers: number; + totalAssignedDomains: number; +} diff --git a/apps/api/src/domains/access-lists/dto/access-lists.dto.ts b/apps/api/src/domains/access-lists/dto/access-lists.dto.ts new file mode 100644 index 0000000..b4ad665 --- /dev/null +++ b/apps/api/src/domains/access-lists/dto/access-lists.dto.ts @@ -0,0 +1,257 @@ +import { body, param, query } from 'express-validator'; + +/** + * Validation rules for creating an access list + */ +export const createAccessListValidation = [ + body('name') + .trim() + .notEmpty() + .withMessage('Name is required') + .isLength({ min: 3, max: 100 }) + .withMessage('Name must be between 3 and 100 characters') + .matches(/^[a-zA-Z0-9_-]+$/) + .withMessage('Name can only contain letters, numbers, underscores, and hyphens'), + + body('description') + .optional() + .trim() + .isLength({ max: 500 }) + .withMessage('Description must not exceed 500 characters'), + + body('type') + .notEmpty() + .withMessage('Type is required') + .isIn(['ip_whitelist', 'http_basic_auth', 'combined']) + .withMessage('Type must be one of: ip_whitelist, http_basic_auth, combined'), + + body('enabled') + .optional() + .isBoolean() + .withMessage('Enabled must be a boolean'), + + body('allowedIps') + .optional() + .isArray() + .withMessage('Allowed IPs must be an array'), + + body('allowedIps.*') + .optional() + .trim() + .matches(/^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/) + .withMessage('Each IP must be a valid IPv4 address or CIDR notation'), + + body('authUsers') + .optional() + .isArray() + .withMessage('Auth users must be an array'), + + body('authUsers.*.username') + .optional() + .trim() + .notEmpty() + .withMessage('Username is required') + .isLength({ min: 3, max: 50 }) + .withMessage('Username must be between 3 and 50 characters') + .matches(/^[a-zA-Z0-9_-]+$/) + .withMessage('Username can only contain letters, numbers, underscores, and hyphens'), + + body('authUsers.*.password') + .optional() + .trim() + .notEmpty() + .withMessage('Password is required') + .isLength({ min: 4 }) + .withMessage('Password must be at least 4 characters'), + + body('authUsers.*.description') + .optional() + .trim() + .isLength({ max: 200 }) + .withMessage('Description must not exceed 200 characters'), + + body('domainIds') + .optional() + .isArray() + .withMessage('Domain IDs must be an array'), + + body('domainIds.*') + .optional() + .trim() + .notEmpty() + .withMessage('Domain ID cannot be empty'), +]; + +/** + * Validation rules for updating an access list + */ +export const updateAccessListValidation = [ + param('id') + .trim() + .notEmpty() + .withMessage('Access list ID is required'), + + body('name') + .optional() + .trim() + .notEmpty() + .withMessage('Name cannot be empty') + .isLength({ min: 3, max: 100 }) + .withMessage('Name must be between 3 and 100 characters') + .matches(/^[a-zA-Z0-9_-]+$/) + .withMessage('Name can only contain letters, numbers, underscores, and hyphens'), + + body('description') + .optional() + .trim() + .isLength({ max: 500 }) + .withMessage('Description must not exceed 500 characters'), + + body('type') + .optional() + .isIn(['ip_whitelist', 'http_basic_auth', 'combined']) + .withMessage('Type must be one of: ip_whitelist, http_basic_auth, combined'), + + body('enabled') + .optional() + .isBoolean() + .withMessage('Enabled must be a boolean'), + + body('allowedIps') + .optional() + .isArray() + .withMessage('Allowed IPs must be an array'), + + body('allowedIps.*') + .optional() + .trim() + .matches(/^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/) + .withMessage('Each IP must be a valid IPv4 address or CIDR notation'), + + body('authUsers') + .optional() + .isArray() + .withMessage('Auth users must be an array'), + + body('authUsers.*.username') + .optional() + .trim() + .notEmpty() + .withMessage('Username is required') + .isLength({ min: 3, max: 50 }) + .withMessage('Username must be between 3 and 50 characters') + .matches(/^[a-zA-Z0-9_-]+$/) + .withMessage('Username can only contain letters, numbers, underscores, and hyphens'), + + body('authUsers.*.password') + .optional({ checkFalsy: true }) // Allow empty string - means keep existing password + .trim() + .custom((value) => { + // If password is provided (not empty), validate it + if (value && value.length > 0 && value.length < 4) { + throw new Error('Password must be at least 4 characters'); + } + return true; + }), + + body('authUsers.*.description') + .optional() + .trim() + .isLength({ max: 200 }) + .withMessage('Description must not exceed 200 characters'), + + body('domainIds') + .optional() + .isArray() + .withMessage('Domain IDs must be an array'), + + body('domainIds.*') + .optional() + .trim() + .notEmpty() + .withMessage('Domain ID cannot be empty'), +]; + +/** + * Validation rules for getting access lists + */ +export const getAccessListsValidation = [ + query('page') + .optional() + .isInt({ min: 1 }) + .withMessage('Page must be a positive integer'), + + query('limit') + .optional() + .isInt({ min: 1, max: 100 }) + .withMessage('Limit must be between 1 and 100'), + + query('search') + .optional() + .trim(), + + query('type') + .optional() + .isIn(['ip_whitelist', 'http_basic_auth', 'combined']) + .withMessage('Type must be one of: ip_whitelist, http_basic_auth, combined'), + + query('enabled') + .optional() + .isBoolean() + .withMessage('Enabled must be a boolean'), +]; + +/** + * Validation rules for getting single access list + */ +export const getAccessListValidation = [ + param('id') + .trim() + .notEmpty() + .withMessage('Access list ID is required'), +]; + +/** + * Validation rules for deleting access list + */ +export const deleteAccessListValidation = [ + param('id') + .trim() + .notEmpty() + .withMessage('Access list ID is required'), +]; + +/** + * Validation rules for applying access list to domain + */ +export const applyToDomainValidation = [ + body('accessListId') + .trim() + .notEmpty() + .withMessage('Access list ID is required'), + + body('domainId') + .trim() + .notEmpty() + .withMessage('Domain ID is required'), + + body('enabled') + .optional() + .isBoolean() + .withMessage('Enabled must be a boolean'), +]; + +/** + * Validation rules for removing access list from domain + */ +export const removeFromDomainValidation = [ + param('accessListId') + .trim() + .notEmpty() + .withMessage('Access list ID is required'), + + param('domainId') + .trim() + .notEmpty() + .withMessage('Domain ID is required'), +]; diff --git a/apps/api/src/domains/access-lists/index.ts b/apps/api/src/domains/access-lists/index.ts new file mode 100644 index 0000000..d18b67a --- /dev/null +++ b/apps/api/src/domains/access-lists/index.ts @@ -0,0 +1,5 @@ +export * from './access-lists.types'; +export * from './access-lists.controller'; +export * from './access-lists.service'; +export * from './access-lists.repository'; +export * from './access-lists.routes'; diff --git a/apps/api/src/domains/access-lists/services/nginx-config.service.ts b/apps/api/src/domains/access-lists/services/nginx-config.service.ts new file mode 100644 index 0000000..beffe85 --- /dev/null +++ b/apps/api/src/domains/access-lists/services/nginx-config.service.ts @@ -0,0 +1,308 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { execSync } from 'child_process'; +import logger from '../../../utils/logger'; +import { AccessListWithRelations } from '../access-lists.types'; + +/** + * Service for generating and managing Nginx configurations for Access Lists + */ +export class NginxConfigService { + private readonly configDir = '/etc/nginx/access-lists'; + private readonly htpasswdDir = '/etc/nginx/htpasswd'; + + constructor() { + this.ensureDirectories(); + } + + /** + * Ensure required directories exist + */ + private async ensureDirectories() { + try { + await fs.mkdir(this.configDir, { recursive: true }); + await fs.mkdir(this.htpasswdDir, { recursive: true }); + } catch (error) { + logger.error('Failed to create access lists directories', error); + } + } + + /** + * Generate IP whitelist configuration + */ + private generateIpWhitelistConfig(accessList: AccessListWithRelations): string { + if (!accessList.allowedIps || accessList.allowedIps.length === 0) { + return '# No IPs configured\ndeny all;'; + } + + const config: string[] = []; + config.push(`# IP Whitelist: ${accessList.name}`); + config.push(`# ${accessList.description || 'No description'}`); + config.push(''); + + // Add allow rules for each IP + accessList.allowedIps.forEach((ip) => { + config.push(`allow ${ip};`); + }); + + // Deny all other IPs + config.push('deny all;'); + + return config.join('\n'); + } + + /** + * Generate HTTP Basic Auth configuration + */ + private async generateBasicAuthConfig( + accessList: AccessListWithRelations + ): Promise { + const htpasswdFile = path.join(this.htpasswdDir, `${accessList.name}.htpasswd`); + + const config: string[] = []; + config.push(`# HTTP Basic Auth: ${accessList.name}`); + config.push(`# ${accessList.description || 'No description'}`); + config.push(''); + config.push('auth_basic "Restricted Access";'); + config.push(`auth_basic_user_file ${htpasswdFile};`); + + return config.join('\n'); + } + + /** + * Generate combined configuration (IP + Basic Auth) + */ + private async generateCombinedConfig( + accessList: AccessListWithRelations + ): Promise { + const ipConfig = this.generateIpWhitelistConfig(accessList); + const authConfig = await this.generateBasicAuthConfig(accessList); + + const config: string[] = []; + config.push(`# Combined Access List: ${accessList.name}`); + config.push(`# ${accessList.description || 'No description'}`); + config.push(''); + config.push('# IP Whitelist'); + config.push(ipConfig); + config.push(''); + config.push('# HTTP Basic Authentication'); + config.push(authConfig); + + return config.join('\n'); + } + + /** + * Validate username to prevent command injection + * Username should only contain safe characters + */ + private validateUsername(username: string): string { + // Check for null bytes which can be used for command injection + if (username.includes('\0')) { + throw new Error('Username contains invalid null byte character'); + } + + // Username should only contain alphanumeric, dash, underscore, dot, and @ + const validUsernamePattern = /^[a-zA-Z0-9._@-]+$/; + if (!validUsernamePattern.test(username)) { + throw new Error('Username contains invalid characters. Only alphanumeric, dash, underscore, dot, and @ are allowed'); + } + + // Length check + if (username.length === 0 || username.length > 255) { + throw new Error('Username must be between 1 and 255 characters'); + } + + return username; + } + + /** + * Escape password for safe use in shell command + * We need to escape single quotes and backslashes to prevent injection + */ + private escapePassword(password: string): string { + // Check for null bytes + if (password.includes('\0')) { + throw new Error('Password contains invalid null byte character'); + } + + // Length check + if (password.length === 0 || password.length > 255) { + throw new Error('Password must be between 1 and 255 characters'); + } + + // Escape single quotes by replacing ' with '\'' + // This allows the password to be safely wrapped in single quotes + return password.replace(/'/g, "'\\''"); + } + + /** + * Generate htpasswd file for HTTP Basic Auth using htpasswd tool + */ + private async generateHtpasswdFile(accessList: AccessListWithRelations): Promise { + if (!accessList.authUsers || accessList.authUsers.length === 0) { + return; + } + + const htpasswdFile = path.join(this.htpasswdDir, `${accessList.name}.htpasswd`); + + // Remove existing file first + try { + await fs.unlink(htpasswdFile); + } catch (error: any) { + if (error.code !== 'ENOENT') { + logger.warn(`Failed to delete existing htpasswd file: ${error.message}`); + } + } + + // Use htpasswd tool to generate entries with proper apr1/MD5 hash format + for (const user of accessList.authUsers) { + try { + // Validate username (strict) and escape password (allow all chars but escape properly) + const safeUsername = this.validateUsername(user.username); + const escapedPassword = this.escapePassword(user.passwordHash); + + // -b: batch mode (password on command line) + // -B: use bcrypt (if you want bcrypt, but apr1 is more compatible) + // -m: use MD5 (default, most compatible with Nginx) + // -c: create new file (only for first user) + const isFirstUser = accessList.authUsers.indexOf(user) === 0; + const createFlag = isFirstUser ? '-c' : ''; + + // Use single quotes for password to prevent variable expansion and command substitution + // Escaped password is safe to use in single quotes + execSync( + `htpasswd -b -m ${createFlag} ${htpasswdFile} "${safeUsername}" '${escapedPassword}'`, + { stdio: 'pipe' } + ); + } catch (error: any) { + logger.error(`Failed to add user ${user.username} to htpasswd: ${error.message}`); + throw error; + } + } + + // Set proper permissions + await fs.chmod(htpasswdFile, 0o644); + logger.info(`Generated htpasswd file with ${accessList.authUsers.length} users: ${htpasswdFile}`); + } + + /** + * Generate access list configuration file + */ + async generateConfig(accessList: AccessListWithRelations): Promise { + try { + let config = ''; + + switch (accessList.type) { + case 'ip_whitelist': + config = this.generateIpWhitelistConfig(accessList); + break; + + case 'http_basic_auth': + await this.generateHtpasswdFile(accessList); + config = await this.generateBasicAuthConfig(accessList); + break; + + case 'combined': + await this.generateHtpasswdFile(accessList); + config = await this.generateCombinedConfig(accessList); + break; + + default: + throw new Error(`Unknown access list type: ${accessList.type}`); + } + + const configFile = path.join(this.configDir, `${accessList.name}.conf`); + await fs.writeFile(configFile, config, { mode: 0o644 }); + + logger.info(`Generated access list config: ${configFile}`); + return configFile; + } catch (error) { + logger.error('Failed to generate access list config', error); + throw error; + } + } + + /** + * Delete access list configuration + */ + async deleteConfig(accessListName: string): Promise { + try { + const configFile = path.join(this.configDir, `${accessListName}.conf`); + const htpasswdFile = path.join(this.htpasswdDir, `${accessListName}.htpasswd`); + + // Delete config file + try { + await fs.unlink(configFile); + logger.info(`Deleted access list config: ${configFile}`); + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + // Delete htpasswd file + try { + await fs.unlink(htpasswdFile); + logger.info(`Deleted htpasswd file: ${htpasswdFile}`); + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw error; + } + } + } catch (error) { + logger.error('Failed to delete access list config', error); + throw error; + } + } + + /** + * Get include path for access list config + */ + getIncludePath(accessListName: string): string { + return path.join(this.configDir, `${accessListName}.conf`); + } + + /** + * Test Nginx configuration + */ + async testConfig(): Promise<{ success: boolean; message: string }> { + try { + execSync('nginx -t', { stdio: 'pipe' }); + return { + success: true, + message: 'Nginx configuration is valid', + }; + } catch (error: any) { + return { + success: false, + message: error.stderr?.toString() || 'Nginx configuration test failed', + }; + } + } + + /** + * Reload Nginx + */ + async reloadNginx(): Promise<{ success: boolean; message: string }> { + try { + // Test config first + const testResult = await this.testConfig(); + if (!testResult.success) { + return testResult; + } + + execSync('nginx -s reload', { stdio: 'pipe' }); + return { + success: true, + message: 'Nginx reloaded successfully', + }; + } catch (error: any) { + return { + success: false, + message: error.stderr?.toString() || 'Failed to reload Nginx', + }; + } + } +} + +export const nginxConfigService = new NginxConfigService(); diff --git a/apps/api/src/domains/backup/backup.service.ts b/apps/api/src/domains/backup/backup.service.ts index 57d408c..2056bba 100644 --- a/apps/api/src/domains/backup/backup.service.ts +++ b/apps/api/src/domains/backup/backup.service.ts @@ -1097,6 +1097,16 @@ export class BackupService { const backendCount = domain.upstreams?.length || 0; const keepaliveConnections = backendCount * 10; + // Generate WebSocket map block + const websocketMapBlock = ` +# WebSocket support - Map for connection upgrade +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +`; + const upstreamBlock = ` upstream ${domain.name.replace(/\./g, '_')}_backend { ${domain.loadBalancer?.algorithm === 'least_conn' ? 'least_conn;' : ''} @@ -1141,6 +1151,14 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # WebSocket timeout settings + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; } location /nginx_health { @@ -1184,6 +1202,14 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # WebSocket timeout settings + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; } location /nginx_health { @@ -1195,7 +1221,7 @@ server { `; } - const fullConfig = upstreamBlock + httpServerBlock + httpsServerBlock; + const fullConfig = websocketMapBlock + upstreamBlock + httpServerBlock + httpsServerBlock; await fs.mkdir(BACKUP_CONSTANTS.NGINX_SITES_AVAILABLE, { recursive: true }); await fs.mkdir(BACKUP_CONSTANTS.NGINX_SITES_ENABLED, { recursive: true }); diff --git a/apps/api/src/domains/backup/services/backup-operations.service.ts b/apps/api/src/domains/backup/services/backup-operations.service.ts index 902b00c..634f64a 100644 --- a/apps/api/src/domains/backup/services/backup-operations.service.ts +++ b/apps/api/src/domains/backup/services/backup-operations.service.ts @@ -321,6 +321,11 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket Support + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_http_version 1.1; ${ hasHttpsUpstream @@ -408,6 +413,11 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket Support + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_http_version 1.1; ${ hasHttpsUpstream diff --git a/apps/api/src/domains/domains/domains.controller.ts b/apps/api/src/domains/domains/domains.controller.ts index 7cabaa3..f2146db 100644 --- a/apps/api/src/domains/domains/domains.controller.ts +++ b/apps/api/src/domains/domains/domains.controller.ts @@ -98,7 +98,7 @@ export class DomainsController { return; } - const { name, upstreams, loadBalancer, modsecEnabled, realIpConfig } = req.body; + const { name, upstreams, loadBalancer, modsecEnabled, realIpConfig, advancedConfig } = req.body; const domain = await domainsService.createDomain( { @@ -107,6 +107,7 @@ export class DomainsController { loadBalancer, modsecEnabled, realIpConfig, + advancedConfig, }, req.user!.userId, req.user!.username, @@ -152,7 +153,7 @@ export class DomainsController { } const { id } = req.params; - const { name, status, modsecEnabled, upstreams, loadBalancer, realIpConfig } = req.body; + const { name, status, modsecEnabled, upstreams, loadBalancer, realIpConfig, advancedConfig } = req.body; const domain = await domainsService.updateDomain( id, @@ -163,6 +164,7 @@ export class DomainsController { upstreams, loadBalancer, realIpConfig, + advancedConfig, }, req.user!.userId, req.user!.username, diff --git a/apps/api/src/domains/domains/domains.repository.ts b/apps/api/src/domains/domains/domains.repository.ts index 0562765..3123e83 100644 --- a/apps/api/src/domains/domains/domains.repository.ts +++ b/apps/api/src/domains/domains/domains.repository.ts @@ -104,6 +104,11 @@ export class DomainsRepository { loadBalancer: true, sslCertificate: true, modsecRules: true, + accessLists: { + include: { + accessList: true, + }, + }, }, }); @@ -121,6 +126,11 @@ export class DomainsRepository { loadBalancer: true, sslCertificate: true, modsecRules: true, + accessLists: { + include: { + accessList: true, + }, + }, }, }); @@ -139,6 +149,11 @@ export class DomainsRepository { realIpEnabled: input.realIpConfig?.realIpEnabled || false, realIpCloudflare: input.realIpConfig?.realIpCloudflare || false, realIpCustomCidrs: input.realIpConfig?.realIpCustomCidrs || [], + // Advanced configuration + hstsEnabled: input.advancedConfig?.hstsEnabled || false, + http2Enabled: input.advancedConfig?.http2Enabled !== undefined ? input.advancedConfig.http2Enabled : true, + grpcEnabled: input.advancedConfig?.grpcEnabled || false, + customLocations: input.advancedConfig?.customLocations ? JSON.parse(JSON.stringify(input.advancedConfig.customLocations)) : null, upstreams: { create: input.upstreams.map((u: CreateUpstreamData) => ({ host: u.host, @@ -226,6 +241,23 @@ export class DomainsRepository { input.realIpConfig?.realIpCustomCidrs !== undefined ? input.realIpConfig.realIpCustomCidrs : currentDomain.realIpCustomCidrs, + // Advanced configuration + hstsEnabled: + input.advancedConfig?.hstsEnabled !== undefined + ? input.advancedConfig.hstsEnabled + : currentDomain.hstsEnabled, + http2Enabled: + input.advancedConfig?.http2Enabled !== undefined + ? input.advancedConfig.http2Enabled + : currentDomain.http2Enabled, + grpcEnabled: + input.advancedConfig?.grpcEnabled !== undefined + ? input.advancedConfig.grpcEnabled + : currentDomain.grpcEnabled, + customLocations: + input.advancedConfig?.customLocations !== undefined + ? JSON.parse(JSON.stringify(input.advancedConfig.customLocations)) + : currentDomain.customLocations, }, }); diff --git a/apps/api/src/domains/domains/domains.service.ts b/apps/api/src/domains/domains/domains.service.ts index bd97610..c114062 100644 --- a/apps/api/src/domains/domains/domains.service.ts +++ b/apps/api/src/domains/domains/domains.service.ts @@ -78,6 +78,26 @@ export class DomainsService { return updatedDomain; } + /** + * Regenerate nginx configuration for a domain + * Used when domain-related configurations change (e.g., access lists) + */ + async regenerateConfig(domainId: string): Promise { + // Get domain with all relations including access lists + const domain = await domainsRepository.findById(domainId); + if (!domain) { + throw new Error('Domain not found'); + } + + // Regenerate nginx config + await nginxConfigService.generateConfig(domain); + + // Auto-reload nginx + await nginxReloadService.autoReload(true); + + logger.info(`Regenerated nginx config for domain ${domain.name}`); + } + /** * Update domain */ diff --git a/apps/api/src/domains/domains/domains.types.ts b/apps/api/src/domains/domains/domains.types.ts index fa4ee73..33dba6a 100644 --- a/apps/api/src/domains/domains/domains.types.ts +++ b/apps/api/src/domains/domains/domains.types.ts @@ -1,4 +1,4 @@ -import { Domain, Upstream, LoadBalancerConfig, SSLCertificate, ModSecRule } from '@prisma/client'; +import { Domain, Upstream, LoadBalancerConfig, SSLCertificate, ModSecRule, AccessListDomain, AccessList } from '@prisma/client'; /** * Domain types and interfaces @@ -10,6 +10,7 @@ export interface DomainWithRelations extends Domain { loadBalancer: LoadBalancerConfig | null; sslCertificate: SSLCertificate | null; modsecRules?: ModSecRule[]; + accessLists?: (AccessListDomain & { accessList: AccessList })[]; } // Upstream creation data @@ -39,6 +40,22 @@ export interface RealIpConfigData { realIpCustomCidrs?: string[]; } +// Custom location configuration +export interface CustomLocationData { + path: string; // Location path (e.g., /api, /admin) + upstreamType: 'proxy_pass' | 'grpc_pass' | 'grpcs_pass'; // Type of upstream + upstreams: CreateUpstreamData[]; // Upstream servers for this location + config?: string; // Additional custom nginx config +} + +// Advanced configuration data +export interface AdvancedConfigData { + hstsEnabled?: boolean; // Enable HSTS header + http2Enabled?: boolean; // Enable HTTP/2 + grpcEnabled?: boolean; // Enable gRPC support (default proxy_pass replacement) + customLocations?: CustomLocationData[]; // Custom location blocks +} + // Domain creation input export interface CreateDomainInput { name: string; @@ -46,6 +63,7 @@ export interface CreateDomainInput { loadBalancer?: LoadBalancerConfigData; modsecEnabled?: boolean; realIpConfig?: RealIpConfigData; + advancedConfig?: AdvancedConfigData; } // Domain update input @@ -56,6 +74,7 @@ export interface UpdateDomainInput { upstreams?: CreateUpstreamData[]; loadBalancer?: LoadBalancerConfigData; realIpConfig?: RealIpConfigData; + advancedConfig?: AdvancedConfigData; } // Domain query filters diff --git a/apps/api/src/domains/domains/services/nginx-config.service.ts b/apps/api/src/domains/domains/services/nginx-config.service.ts index 3210c18..c1b9abc 100644 --- a/apps/api/src/domains/domains/services/nginx-config.service.ts +++ b/apps/api/src/domains/domains/services/nginx-config.service.ts @@ -1,10 +1,14 @@ import * as fs from 'fs/promises'; import * as path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; import logger from '../../../utils/logger'; import { PATHS } from '../../../shared/constants/paths.constants'; import { DomainWithRelations } from '../domains.types'; import { cloudflareIpsService } from './cloudflare-ips.service'; +const execAsync = promisify(exec); + /** * Service for generating Nginx configuration files */ @@ -12,12 +16,35 @@ export class NginxConfigService { private readonly sitesAvailable = PATHS.NGINX.SITES_AVAILABLE; private readonly sitesEnabled = PATHS.NGINX.SITES_ENABLED; + /** + * Validate nginx configuration syntax + * @returns {Promise<{valid: boolean, error?: string}>} + */ + async validateNginxConfig(): Promise<{ valid: boolean; error?: string }> { + try { + const { stdout, stderr } = await execAsync('nginx -t 2>&1'); + const output = stdout + stderr; + + if (output.includes('syntax is ok') && output.includes('test is successful')) { + logger.info('Nginx configuration validation passed'); + return { valid: true }; + } + + logger.error('Nginx configuration validation failed:', output); + return { valid: false, error: output }; + } catch (error: any) { + logger.error('Nginx configuration validation error:', error.message); + return { valid: false, error: error.message }; + } + } + /** * Generate complete Nginx configuration for a domain */ async generateConfig(domain: DomainWithRelations): Promise { const configPath = path.join(this.sitesAvailable, `${domain.name}.conf`); const enabledPath = path.join(this.sitesEnabled, `${domain.name}.conf`); + const backupPath = path.join(this.sitesAvailable, `${domain.name}.conf.backup`); // Debug logging logger.info(`Generating nginx config for ${domain.name}:`); @@ -38,6 +65,15 @@ export class NginxConfigService { try { await fs.mkdir(this.sitesAvailable, { recursive: true }); await fs.mkdir(this.sitesEnabled, { recursive: true }); + + // Backup existing config if it exists + try { + await fs.copyFile(configPath, backupPath); + logger.info(`Backed up existing config to ${backupPath}`); + } catch (e) { + // No existing config to backup + } + await fs.writeFile(configPath, fullConfig); // Create symlink if domain is active @@ -50,9 +86,32 @@ export class NginxConfigService { await fs.symlink(configPath, enabledPath); } - logger.info(`Nginx configuration generated for ${domain.name}`); + // Validate nginx configuration + const validation = await this.validateNginxConfig(); + + if (!validation.valid) { + // Restore backup if validation fails + logger.error(`Nginx config validation failed for ${domain.name}, restoring backup`); + try { + await fs.copyFile(backupPath, configPath); + logger.info('Backup restored successfully'); + } catch (restoreError) { + logger.error('Failed to restore backup:', restoreError); + } + + throw new Error(`Invalid nginx configuration: ${validation.error}`); + } + + logger.info(`Nginx configuration generated and validated for ${domain.name}`); + + // Clean up backup after successful validation + try { + await fs.unlink(backupPath); + } catch (e) { + // Ignore cleanup errors + } } catch (error) { - logger.error(`Failed to write nginx config for ${domain.name}:`, error); + logger.error(`Failed to generate nginx config for ${domain.name}:`, error); throw error; } } @@ -64,7 +123,7 @@ export class NginxConfigService { const upstreamName = domain.name.replace(/\./g, '_'); const algorithm = domain.loadBalancer?.algorithm || 'round_robin'; - const algorithmDirectives = []; + const algorithmDirectives: string[] = []; if (algorithm === 'least_conn') { algorithmDirectives.push('least_conn;'); } else if (algorithm === 'ip_hash') { @@ -78,7 +137,7 @@ export class NginxConfigService { // Calculate keepalive connections: 10 connections per backend const keepaliveConnections = domain.upstreams.length * 10; - return ` + let upstreamBlocks = ` upstream ${upstreamName}_backend { ${algorithmDirectives.join('\n ')} ${algorithmDirectives.length > 0 ? '\n ' : ''}${servers} @@ -87,6 +146,54 @@ upstream ${upstreamName}_backend { keepalive ${keepaliveConnections}; } `; + + // Generate upstream blocks for custom locations + if (domain.customLocations && typeof domain.customLocations === 'object') { + try { + const locations = Array.isArray(domain.customLocations) + ? domain.customLocations + : (domain.customLocations as any).locations || []; + + locations.forEach((loc: any) => { + // Skip if config already contains proxy_pass or grpc_pass (user manages their own upstream) + const hasProxyDirective = loc.config && ( + loc.config.includes('proxy_pass') || + loc.config.includes('grpc_pass') + ); + + if (hasProxyDirective) { + return; // Skip upstream generation + } + + // Only generate upstream if we have valid upstreams with non-empty hosts + if (loc.upstreams && Array.isArray(loc.upstreams) && loc.upstreams.length > 0) { + const validUpstreams = loc.upstreams.filter((u: any) => u.host && u.host.trim() !== ''); + + if (validUpstreams.length === 0) { + return; // Skip if no valid upstreams + } + + const locationUpstreamName = `${domain.name.replace(/\./g, '_')}_${loc.path.replace(/[^a-zA-Z0-9]/g, '_')}`; + const locationServers = validUpstreams.map((u: any) => + `server ${u.host}:${u.port} weight=${u.weight || 1} max_fails=${u.maxFails || 3} fail_timeout=${u.failTimeout || 10}s;` + ).join('\n '); + + upstreamBlocks += ` +upstream ${locationUpstreamName}_backend { + ${algorithmDirectives.join('\n ')} + ${algorithmDirectives.length > 0 ? '\n ' : ''}${locationServers} + + keepalive ${validUpstreams.length * 10}; +} +`; + } + }); + } catch (error) { + logger.error('Failed to generate custom location upstreams:', error); + } + } + + return upstreamBlocks; } /** @@ -120,6 +227,9 @@ ${realIpBlock} `; } + // Generate Access Lists block + const accessListsBlock = this.generateAccessListsBlock(domain); + // HTTP server with full proxy configuration return ` server { @@ -127,6 +237,7 @@ server { server_name ${domain.name}; ${realIpBlock} +${accessListsBlock} # Include ACL rules (IP whitelist/blacklist) include /etc/nginx/conf.d/acl-rules.conf; @@ -170,13 +281,28 @@ ${realIpBlock} // Generate Real IP block const realIpBlock = await this.generateRealIpBlock(domain); + + // Generate HSTS header if enabled + const hstsHeader = domain.hstsEnabled + ? 'add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;' + : 'add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;'; + + // HTTP/2 support (enabled by default, can be disabled) + const http2Support = domain.http2Enabled !== false ? ' http2' : ''; + + // Generate custom locations if configured + const customLocations = this.generateCustomLocations(domain); + + // Generate Access Lists block + const accessListsBlock = this.generateAccessListsBlock(domain); return ` server { - listen 443 ssl http2; + listen 443 ssl${http2Support}; server_name ${domain.name}; ${realIpBlock} +${accessListsBlock} # Include ACL rules (IP whitelist/blacklist) include /etc/nginx/conf.d/acl-rules.conf; @@ -195,7 +321,7 @@ ${realIpBlock} ssl_stapling_verify on; # Security Headers - add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + ${hstsHeader} add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; @@ -205,13 +331,9 @@ ${realIpBlock} access_log /var/log/nginx/${domain.name}_ssl_access.log main; error_log /var/log/nginx/${domain.name}_ssl_error.log warn; +${customLocations} location / { - ${this.generateProxyHeaders(domain)} - proxy_pass ${upstreamProtocol}://${upstreamName}_backend; - - ${this.generateHttpsBackendSettings(domain)} - - ${this.generateHealthCheckSettings(domain)} + ${domain.grpcEnabled ? this.generateGrpcLocationBlock(domain, upstreamName) : this.generateProxyLocationBlock(domain, upstreamName, upstreamProtocol)} } location /nginx_health { @@ -264,14 +386,44 @@ ${realIpBlock} return lines.join('\n'); } + /** + * Generate Access Lists includes for a domain + */ + private generateAccessListsBlock(domain: DomainWithRelations): string { + // Check if domain has access lists + if (!domain.accessLists || domain.accessLists.length === 0) { + return ''; + } + + const lines: string[] = []; + lines.push(' # Access Lists Configuration'); + + // Include each enabled access list + domain.accessLists + .filter(al => al.enabled && al.accessList.enabled) + .forEach(al => { + const configFile = `/etc/nginx/access-lists/${al.accessList.name}.conf`; + lines.push(` include ${configFile};`); + }); + + lines.push(''); + return lines.join('\n'); + } + /** * Generate proxy headers for passing client information to backend + * Includes WebSocket support headers by default */ private generateProxyHeaders(domain: DomainWithRelations): string { return `proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme;`; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket Support + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_http_version 1.1;`; } /** @@ -298,7 +450,7 @@ ${realIpBlock} } /** - * Generate health check settings + * Generate health check settings for HTTP/HTTPS proxy */ private generateHealthCheckSettings(domain: DomainWithRelations): string { if (!domain.loadBalancer?.healthCheckEnabled) { @@ -313,6 +465,228 @@ ${realIpBlock} `; } + /** + * Generate health check settings for gRPC + */ + private generateGrpcHealthCheckSettings(domain: DomainWithRelations): string { + if (!domain.loadBalancer?.healthCheckEnabled) { + return ''; + } + + return ` + # gRPC Health check settings + grpc_next_upstream error timeout http_502 http_503 http_504; + grpc_next_upstream_tries 3; + grpc_next_upstream_timeout ${domain.loadBalancer.healthCheckTimeout}s; + `; + } + + /** + * Generate proxy_pass directive + */ + private generateProxyPass(domain: DomainWithRelations, upstreamName: string, protocol: string): string { + return `proxy_pass ${protocol}://${upstreamName}_backend;`; + } + + /** + * Generate grpc_pass directive with proper scheme + * Note: Only grpc_pass directive exists. Use grpc:// or grpcs:// scheme for protocol + */ + private generateGrpcPass(domain: DomainWithRelations, upstreamName: string): string { + const hasHttpsUpstream = domain.upstreams.some((u) => u.protocol === 'https'); + const grpcScheme = hasHttpsUpstream ? 'grpcs://' : 'grpc://'; + return `grpc_pass ${grpcScheme}${upstreamName}_backend;`; + } + + /** + * Generate complete gRPC location block with SSL settings + */ + private generateGrpcLocationBlock(domain: DomainWithRelations, upstreamName: string): string { + const hasHttpsUpstream = domain.upstreams.some((u) => u.protocol === 'https'); + const grpcScheme = hasHttpsUpstream ? 'grpcs://' : 'grpc://'; + + const shouldVerify = domain.upstreams.some( + (u) => u.protocol === 'https' && u.sslVerify + ); + + // gRPC SSL settings (if using HTTPS/TLS backend) + let sslSettings = ''; + if (hasHttpsUpstream) { + sslSettings = ` + # gRPC SSL Backend Settings + ${shouldVerify ? 'grpc_ssl_verify on;' : 'grpc_ssl_verify off;'} + grpc_ssl_server_name on; + grpc_ssl_name ${domain.name}; + grpc_ssl_protocols TLSv1.2 TLSv1.3;`; + } + + // gRPC timeout and error handling (equivalent to proxy_next_upstream) + // Note: grpc_next_upstream valid values: error, timeout, invalid_header, http_500, http_502, http_503, http_504, http_403, http_404, http_429, non_idempotent, off + let healthCheckSettings = ''; + if (domain.loadBalancer?.healthCheckEnabled) { + healthCheckSettings = ` + # gRPC error handling and failover + grpc_next_upstream error timeout http_502 http_503 http_504; + grpc_next_upstream_tries 3; + grpc_next_upstream_timeout ${domain.loadBalancer.healthCheckTimeout}s;`; + } + + // gRPC timeouts (equivalent to proxy timeouts) + const timeoutSettings = ` + # gRPC timeouts + grpc_connect_timeout 60s; + grpc_send_timeout 60s; + grpc_read_timeout 60s;`; + + return `# gRPC Configuration + grpc_pass ${grpcScheme}${upstreamName}_backend; +${sslSettings} +${timeoutSettings} +${healthCheckSettings}`; + } + + /** + * Generate complete proxy location block with SSL settings + */ + private generateProxyLocationBlock(domain: DomainWithRelations, upstreamName: string, protocol: string): string { + return `${this.generateProxyHeaders(domain)} + + # Proxy Configuration + proxy_pass ${protocol}://${upstreamName}_backend; + + ${this.generateHttpsBackendSettings(domain)} + + ${this.generateHealthCheckSettings(domain)}`; + } + + /** + * Generate custom location blocks + */ + private generateCustomLocations(domain: DomainWithRelations): string { + if (!domain.customLocations || typeof domain.customLocations !== 'object') { + return ''; + } + + try { + const locations = Array.isArray(domain.customLocations) + ? domain.customLocations + : (domain.customLocations as any).locations || []; + + if (locations.length === 0) { + return ''; + } + + return locations.map((loc: any) => { + const { path: locPath, useUpstream, upstreamType, upstreams, config } = loc; + + // Case 1: User disabled upstream (useUpstream = false) - use custom config only + if (useUpstream === false) { + if (!config || config.trim() === '') { + logger.warn(`Custom location ${locPath} has no config and upstream disabled, skipping`); + return ''; + } + + return ` + location ${locPath} { + ${config} + }`; + } + + // Case 2: User enabled upstream (useUpstream = true) - generate upstream-based config + if (useUpstream === true) { + // Validate that upstreams exist and have valid hosts + if (!upstreams || !Array.isArray(upstreams) || upstreams.length === 0) { + logger.warn(`Custom location ${locPath} has upstream enabled but no upstreams defined, skipping`); + return ''; + } + + const validUpstreams = upstreams.filter((u: any) => u.host && u.host.trim() !== ''); + if (validUpstreams.length === 0) { + logger.warn(`Custom location ${locPath} has no valid upstream hosts, skipping`); + return ''; + } + + const locationUpstreamName = `${domain.name.replace(/\./g, '_')}_${locPath.replace(/[^a-zA-Z0-9]/g, '_')}`; + + // Determine directive based on type + // Important: Add trailing slash (/) to strip location path before proxying + let proxyDirective = ''; + if (upstreamType === 'grpc_pass') { + proxyDirective = `grpc_pass grpc://${locationUpstreamName}_backend/;`; + } else if (upstreamType === 'grpcs_pass') { + proxyDirective = `grpc_pass grpcs://${locationUpstreamName}_backend/;`; + } else { + const hasHttpsUpstream = upstreams?.some((u: any) => u.protocol === 'https'); + const protocol = hasHttpsUpstream ? 'https' : 'http'; + proxyDirective = `proxy_pass ${protocol}://${locationUpstreamName}_backend/;`; + } + + return ` + location ${locPath} { + ${this.generateProxyHeaders(domain)} + ${proxyDirective} + ${config ? '\n ' + config : ''} + }`; + } + + // Case 3: Legacy - no useUpstream field (backward compatibility) + // Check if config already contains proxy_pass or grpc_pass + const hasProxyDirective = config && ( + config.includes('proxy_pass') || + config.includes('grpc_pass') + ); + + if (hasProxyDirective) { + return ` + location ${locPath} { + ${config} + }`; + } + + // Generate upstream-based config if upstreams exist + if (upstreams && Array.isArray(upstreams) && upstreams.length > 0) { + const validUpstreams = upstreams.filter((u: any) => u.host && u.host.trim() !== ''); + if (validUpstreams.length > 0) { + const locationUpstreamName = `${domain.name.replace(/\./g, '_')}_${locPath.replace(/[^a-zA-Z0-9]/g, '_')}`; + + // Add trailing slash (/) to strip location path before proxying + let proxyDirective = ''; + if (upstreamType === 'grpc_pass') { + proxyDirective = `grpc_pass grpc://${locationUpstreamName}_backend/;`; + } else if (upstreamType === 'grpcs_pass') { + proxyDirective = `grpc_pass grpcs://${locationUpstreamName}_backend/;`; + } else { + const hasHttpsUpstream = upstreams?.some((u: any) => u.protocol === 'https'); + const protocol = hasHttpsUpstream ? 'https' : 'http'; + proxyDirective = `proxy_pass ${protocol}://${locationUpstreamName}_backend/;`; + } + + return ` + location ${locPath} { + ${this.generateProxyHeaders(domain)} + ${proxyDirective} + ${config ? '\n ' + config : ''} + }`; + } + } + + // Fallback: just use config if provided + if (config && config.trim() !== '') { + return ` + location ${locPath} { + ${config} + }`; + } + + logger.warn(`Custom location ${locPath} has no valid configuration, skipping`); + return ''; + }).filter((loc: string) => loc !== '').join('\n'); + } catch (error) { + logger.error('Failed to generate custom locations:', error); + return ''; + } + } + /** * Delete nginx configuration for a domain */ diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 478a37c..160d67c 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -9,6 +9,7 @@ import modsecRoutes from '../domains/modsec/modsec.routes'; import logsRoutes from '../domains/logs/logs.routes'; import alertsRoutes from '../domains/alerts/alerts.routes'; import aclRoutes from '../domains/acl/acl.routes'; +import accessListsRoutes from '../domains/access-lists/access-lists.routes'; import performanceRoutes from '../domains/performance/performance.routes'; import userRoutes from '../domains/users/users.routes'; import dashboardRoutes from '../domains/dashboard/dashboard.routes'; @@ -38,6 +39,7 @@ router.use('/modsec', modsecRoutes); router.use('/logs', logsRoutes); router.use('/alerts', alertsRoutes); router.use('/acl', aclRoutes); +router.use('/access-lists', accessListsRoutes); router.use('/performance', performanceRoutes); router.use('/users', userRoutes); router.use('/dashboard', dashboardRoutes); diff --git a/apps/web/src/components/access-lists/AccessListCard.tsx b/apps/web/src/components/access-lists/AccessListCard.tsx new file mode 100644 index 0000000..41711cc --- /dev/null +++ b/apps/web/src/components/access-lists/AccessListCard.tsx @@ -0,0 +1,242 @@ +import { useState } from 'react'; +import { Edit, Trash2, Globe, Shield, ShieldCheck, Power } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { useToast } from '@/hooks/use-toast'; +import { useDeleteAccessList, useToggleAccessList } from '@/queries/access-lists.query-options'; +import { AccessListFormDialog } from './AccessListFormDialog'; +import type { AccessList } from '@/services/access-lists.service'; + +interface AccessListCardProps { + accessList: AccessList; +} + +export function AccessListCard({ accessList }: AccessListCardProps) { + const { toast } = useToast(); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const deleteMutation = useDeleteAccessList(); + const toggleMutation = useToggleAccessList(); + + const handleDelete = async () => { + try { + await deleteMutation.mutateAsync(accessList.id); + toast({ + title: 'Success', + description: 'Access list deleted successfully', + }); + setIsDeleteDialogOpen(false); + } catch (error: any) { + toast({ + title: 'Error', + description: error.response?.data?.message || 'Failed to delete access list', + variant: 'destructive', + }); + } + }; + + const handleToggle = async (enabled: boolean) => { + try { + await toggleMutation.mutateAsync({ id: accessList.id, enabled }); + toast({ + title: 'Success', + description: `Access list ${enabled ? 'enabled' : 'disabled'} successfully`, + }); + } catch (error: any) { + toast({ + title: 'Error', + description: error.response?.data?.message || 'Failed to toggle access list', + variant: 'destructive', + }); + } + }; + + const getTypeIcon = () => { + switch (accessList.type) { + case 'ip_whitelist': + return ; + case 'http_basic_auth': + return ; + case 'combined': + return ; + default: + return ; + } + }; + + const getTypeLabel = () => { + switch (accessList.type) { + case 'ip_whitelist': + return 'IP Whitelist'; + case 'http_basic_auth': + return 'HTTP Basic Auth'; + case 'combined': + return 'Combined'; + default: + return accessList.type; + } + }; + + return ( + <> + + +
+
+
+ {accessList.name} + + {accessList.enabled ? 'Enabled' : 'Disabled'} + + + {getTypeIcon()} + {getTypeLabel()} + +
+ {accessList.description && ( +

{accessList.description}

+ )} +
+ +
+ + + +
+
+
+ + +
+ {/* IP Whitelist Info */} + {(accessList.type === 'ip_whitelist' || accessList.type === 'combined') && ( +
+

Allowed IPs

+
+ {accessList.allowedIps && accessList.allowedIps.length > 0 ? ( + accessList.allowedIps.slice(0, 3).map((ip, index) => ( + + {ip} + + )) + ) : ( +

No IPs configured

+ )} + {accessList.allowedIps && accessList.allowedIps.length > 3 && ( + + +{accessList.allowedIps.length - 3} more + + )} +
+
+ )} + + {/* Auth Users Info */} + {(accessList.type === 'http_basic_auth' || accessList.type === 'combined') && ( +
+

Auth Users

+
+ {accessList.authUsers && accessList.authUsers.length > 0 ? ( + accessList.authUsers.slice(0, 3).map((user) => ( + + {user.username} + + )) + ) : ( +

No users configured

+ )} + {accessList.authUsers && accessList.authUsers.length > 3 && ( + + +{accessList.authUsers.length - 3} more + + )} +
+
+ )} + + {/* Assigned Domains */} +
+

+ + Assigned Domains +

+
+ {accessList.domains && accessList.domains.length > 0 ? ( + accessList.domains.slice(0, 3).map((domainLink) => ( + + {domainLink.domain.name} + + )) + ) : ( +

No domains assigned

+ )} + {accessList.domains && accessList.domains.length > 3 && ( + + +{accessList.domains.length - 3} more + + )} +
+
+
+
+
+ + {/* Edit Dialog */} + + + {/* Delete Confirmation Dialog */} + + + + Delete Access List + + Are you sure you want to delete "{accessList.name}"? This action cannot be undone. + + + + Cancel + + {deleteMutation.isPending ? 'Deleting...' : 'Delete'} + + + + + + ); +} diff --git a/apps/web/src/components/access-lists/AccessListFormDialog.tsx b/apps/web/src/components/access-lists/AccessListFormDialog.tsx new file mode 100644 index 0000000..576b059 --- /dev/null +++ b/apps/web/src/components/access-lists/AccessListFormDialog.tsx @@ -0,0 +1,628 @@ +import { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Plus, Trash2, Eye, EyeOff } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { useToast } from '@/hooks/use-toast'; +import { + useCreateAccessList, + useUpdateAccessList, + useRemoveFromDomain, +} from '@/queries/access-lists.query-options'; +import { domainQueryOptions } from '@/queries/domain.query-options'; +import type { AccessList } from '@/services/access-lists.service'; + +interface AccessListFormDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + accessList?: AccessList; +} + +interface AuthUserFormData { + username: string; + password: string; + description?: string; + showPassword?: boolean; +} + +export function AccessListFormDialog({ + open, + onOpenChange, + accessList, +}: AccessListFormDialogProps) { + const { toast } = useToast(); + const isEditMode = !!accessList; + + const createMutation = useCreateAccessList(); + const updateMutation = useUpdateAccessList(); + const removeFromDomainMutation = useRemoveFromDomain(); + + // Fetch domains for selection + const { data: domainsData } = useQuery(domainQueryOptions.all({ page: 1, limit: 100 })); + const domains = domainsData?.data || []; + + const [formData, setFormData] = useState({ + name: '', + description: '', + type: 'ip_whitelist' as 'ip_whitelist' | 'http_basic_auth' | 'combined', + enabled: true, + }); + + const [allowedIps, setAllowedIps] = useState(['']); + const [authUsers, setAuthUsers] = useState([ + { username: '', password: '', description: '', showPassword: false }, + ]); + const [selectedDomains, setSelectedDomains] = useState([]); + const [originalDomainIds, setOriginalDomainIds] = useState([]); // Track original domains for edit mode + + // Reset form when dialog opens or access list changes + useEffect(() => { + if (open) { + if (accessList) { + // Edit mode + setFormData({ + name: accessList.name, + description: accessList.description || '', + type: accessList.type, + enabled: accessList.enabled, + }); + + setAllowedIps( + accessList.allowedIps && accessList.allowedIps.length > 0 + ? accessList.allowedIps + : [''] + ); + + setAuthUsers( + accessList.authUsers && accessList.authUsers.length > 0 + ? accessList.authUsers.map((u) => ({ + username: u.username, + password: '', // Don't populate password for security + description: u.description || '', + showPassword: false, + })) + : [{ username: '', password: '', description: '', showPassword: false }] + ); + + const domainIds = accessList.domains?.map((d) => d.domainId) || []; + setSelectedDomains(domainIds); + setOriginalDomainIds(domainIds); // Store original for comparison + } else { + // Create mode - reset form + setFormData({ + name: '', + description: '', + type: 'ip_whitelist', + enabled: true, + }); + setAllowedIps(['']); + setAuthUsers([{ username: '', password: '', description: '', showPassword: false }]); + setSelectedDomains([]); + setOriginalDomainIds([]); // Reset original domains + } + } + }, [open, accessList]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // Validation + if (!formData.name.trim()) { + toast({ + title: 'Error', + description: 'Access list name is required', + variant: 'destructive', + }); + return; + } + + // Validate based on type + if (formData.type === 'ip_whitelist' || formData.type === 'combined') { + const validIps = allowedIps.filter((ip) => ip.trim()); + if (validIps.length === 0) { + toast({ + title: 'Error', + description: 'At least one IP address is required for IP whitelist', + variant: 'destructive', + }); + return; + } + } + + if (formData.type === 'http_basic_auth' || formData.type === 'combined') { + // In edit mode, password is optional (empty = keep existing) + // In create mode, password is required + const validUsers = authUsers.filter((u) => { + if (isEditMode) { + return u.username.trim(); // Only username required in edit mode + } + return u.username.trim() && u.password.trim(); // Both required in create mode + }); + + if (validUsers.length === 0) { + toast({ + title: 'Error', + description: 'At least one auth user is required for HTTP Basic Auth', + variant: 'destructive', + }); + return; + } + + // Validate username and password length + for (const user of validUsers) { + if (!user.username.trim()) { + toast({ + title: 'Error', + description: 'Username is required for all auth users', + variant: 'destructive', + }); + return; + } + // In create mode, password is required + // In edit mode, empty password means keep existing password + if (!isEditMode && !user.password.trim()) { + toast({ + title: 'Error', + description: 'Password is required for new auth users', + variant: 'destructive', + }); + return; + } + // If password is provided, validate minimum length + if (user.password.trim() && user.password.length < 4) { + toast({ + title: 'Error', + description: 'Password must be at least 4 characters', + variant: 'destructive', + }); + return; + } + } + } + + const payload = { + ...formData, + allowedIps: + formData.type === 'ip_whitelist' || formData.type === 'combined' + ? allowedIps.filter((ip) => ip.trim()) + : undefined, + authUsers: + formData.type === 'http_basic_auth' || formData.type === 'combined' + ? authUsers + .filter((u) => { + // In create mode, require both username and password + // In edit mode, only require username (empty password = keep existing) + if (isEditMode) { + return u.username.trim(); + } + return u.username.trim() && u.password.trim(); + }) + .map(({ username, password, description }) => ({ + username, + password, // In edit mode, empty password will be handled by backend + description, + })) + : undefined, + domainIds: selectedDomains.length > 0 ? selectedDomains : undefined, + }; + + try { + if (isEditMode) { + // Detect removed domains (domains that were assigned but now unchecked) + const removedDomainIds = originalDomainIds.filter( + (domainId) => !selectedDomains.includes(domainId) + ); + + // Remove domains first if any + if (removedDomainIds.length > 0) { + await Promise.all( + removedDomainIds.map((domainId) => + removeFromDomainMutation.mutateAsync({ + accessListId: accessList.id, + domainId, + }) + ) + ); + } + + // Then update the access list + await updateMutation.mutateAsync({ id: accessList.id, data: payload }); + toast({ + title: 'Success', + description: 'Access list updated successfully', + }); + } else { + await createMutation.mutateAsync(payload); + toast({ + title: 'Success', + description: 'Access list created successfully', + }); + } + onOpenChange(false); + } catch (error: any) { + toast({ + title: 'Error', + description: error.response?.data?.message || 'Failed to save access list', + variant: 'destructive', + }); + } + }; + + const addIpField = () => { + setAllowedIps([...allowedIps, '']); + }; + + const removeIpField = (index: number) => { + setAllowedIps(allowedIps.filter((_, i) => i !== index)); + }; + + const updateIpField = (index: number, value: string) => { + const newIps = [...allowedIps]; + newIps[index] = value; + setAllowedIps(newIps); + }; + + const addAuthUser = () => { + setAuthUsers([ + ...authUsers, + { username: '', password: '', description: '', showPassword: false }, + ]); + }; + + const removeAuthUser = (index: number) => { + setAuthUsers(authUsers.filter((_, i) => i !== index)); + }; + + const updateAuthUser = ( + index: number, + field: keyof AuthUserFormData, + value: string | boolean + ) => { + const newUsers = [...authUsers]; + (newUsers[index] as any)[field] = value; + setAuthUsers(newUsers); + }; + + const toggleDomainSelection = (domainId: string) => { + console.log('Toggling domain:', domainId); + setSelectedDomains((prev) => { + const isSelected = prev.includes(domainId); + console.log('Current selected:', prev); + console.log('Is selected:', isSelected); + const newSelection = isSelected + ? prev.filter((id) => id !== domainId) + : [...prev, domainId]; + console.log('New selection:', newSelection); + return newSelection; + }); + }; + + const isPending = createMutation.isPending || updateMutation.isPending; + + return ( + + + + + {isEditMode ? 'Edit Access List' : 'Create Access List'} + + + {isEditMode + ? 'Update access list configuration' + : 'Create a new access list to restrict access to your domains'} + + + +
+ {/* Basic Information */} +
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + placeholder="e.g., admin-panel-access" + disabled={isPending} + required + /> +
+ +
+ +