diff --git a/backend/src/routes/hostRoutes.js b/backend/src/routes/hostRoutes.js index bbec5f3..fb33da4 100644 --- a/backend/src/routes/hostRoutes.js +++ b/backend/src/routes/hostRoutes.js @@ -847,6 +847,119 @@ router.post( // }, // ); +// Admin endpoint to bulk update host groups (many-to-many) +router.put( + "/bulk/groups", + authenticateToken, + requireManageHosts, + [ + body("hostIds").isArray().withMessage("Host IDs must be an array"), + body("hostIds.*") + .isLength({ min: 1 }) + .withMessage("Each host ID must be provided"), + body("groupIds").isArray().optional(), + body("groupIds.*") + .optional() + .isUUID() + .withMessage("Each group ID must be a valid UUID"), + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { hostIds, groupIds = [] } = req.body; + + // Verify all groups exist if provided + if (groupIds.length > 0) { + const existingGroups = await prisma.host_groups.findMany({ + where: { id: { in: groupIds } }, + select: { id: true }, + }); + + if (existingGroups.length !== groupIds.length) { + return res.status(400).json({ + error: "One or more host groups not found", + provided: groupIds, + found: existingGroups.map((g) => g.id), + }); + } + } + + // Check if all hosts exist + const existingHosts = await prisma.hosts.findMany({ + where: { id: { in: hostIds } }, + select: { id: true, friendly_name: true }, + }); + + if (existingHosts.length !== hostIds.length) { + const foundIds = existingHosts.map((h) => h.id); + const missingIds = hostIds.filter((id) => !foundIds.includes(id)); + return res.status(400).json({ + error: "Some hosts not found", + missingHostIds: missingIds, + }); + } + + // Use transaction to update group memberships for all hosts + const updatedHosts = await prisma.$transaction(async (tx) => { + const results = []; + + for (const hostId of hostIds) { + // Remove existing memberships for this host + await tx.host_group_memberships.deleteMany({ + where: { host_id: hostId }, + }); + + // Add new memberships for this host + if (groupIds.length > 0) { + await tx.host_group_memberships.createMany({ + data: groupIds.map((groupId) => ({ + id: crypto.randomUUID(), + host_id: hostId, + host_group_id: groupId, + })), + }); + } + + // Get updated host with groups + const updatedHost = await tx.hosts.findUnique({ + where: { id: hostId }, + include: { + host_group_memberships: { + include: { + host_groups: { + select: { + id: true, + name: true, + color: true, + }, + }, + }, + }, + }, + }); + + results.push(updatedHost); + } + + return results; + }); + + res.json({ + message: `Successfully updated ${updatedHosts.length} host${updatedHosts.length !== 1 ? "s" : ""}`, + updatedCount: updatedHosts.length, + hosts: updatedHosts, + }); + } catch (error) { + console.error("Bulk host groups update error:", error); + res.status(500).json({ error: "Failed to update host groups" }); + } + }, +); + // Admin endpoint to update host groups (many-to-many) router.put( "/:hostId/groups", diff --git a/docker/README.md b/docker/README.md index d0667c4..952b0a5 100644 --- a/docker/README.md +++ b/docker/README.md @@ -39,15 +39,15 @@ These tags are available for both backend and frontend images as they are versio environment: DATABASE_URL: postgresql://patchmon_user:REPLACE_YOUR_POSTGRES_PASSWORD_HERE@database:5432/patchmon_db ``` -4. Set a Redis password in the Redis service where it says: +4. Set a Redis password in the Redis service command where it says: ```yaml - environment: - REDIS_PASSWORD: # CREATE A STRONG REDIS PASSWORD AND PUT IT HERE + command: redis-server --requirepass your-redis-password-here ``` + Note: The Redis service uses a hardcoded password in the command line for better reliability and to avoid environment variable parsing issues. 5. Update the corresponding `REDIS_PASSWORD` in the backend service where it says: ```yaml environment: - REDIS_PASSWORD: REPLACE_YOUR_REDIS_PASSWORD_HERE + REDIS_PASSWORD: your-redis-password-here ``` 6. Generate a strong JWT secret. You can do this like so: ```bash @@ -123,6 +123,9 @@ When you do this, updating to a new version requires manually updating the image | -------------- | ------------------ | ---------------- | | `REDIS_PASSWORD` | Redis password | **MUST BE SET!** | +> [!NOTE] +> The Redis service uses a hardcoded password in the command line (`redis-server --requirepass your-password`) instead of environment variables or configuration files. This approach eliminates parsing issues and provides better reliability. The password must be set in both the Redis command and the backend service environment variables. + #### Backend Service ##### Database Configuration diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2e55aee..3c9660b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -19,14 +19,11 @@ services: redis: image: redis:7-alpine restart: unless-stopped - command: redis-server /usr/local/etc/redis/redis.conf - environment: - REDIS_PASSWORD: # CREATE A STRONG REDIS PASSWORD AND PUT IT HERE + command: redis-server --requirepass your-redis-password-here volumes: - redis_data:/data - - ./docker/redis.conf:/usr/local/etc/redis/redis.conf:ro healthcheck: - test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping"] + test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "your-redis-password-here", "ping"] interval: 3s timeout: 5s retries: 7 @@ -46,7 +43,7 @@ services: # Redis Configuration REDIS_HOST: redis REDIS_PORT: 6379 - REDIS_PASSWORD: REPLACE_YOUR_REDIS_PASSWORD_HERE + REDIS_PASSWORD: your-redis-password-here REDIS_DB: 0 volumes: - agent_files:/app/agents diff --git a/docker/redis.conf b/docker/redis.conf index 1759bfa..b0b7640 100644 --- a/docker/redis.conf +++ b/docker/redis.conf @@ -1,10 +1,10 @@ # Redis Configuration for PatchMon Production # Security settings -requirepass ${REDIS_PASSWORD} +# requirepass ${REDIS_PASSWORD} # Disabled - using command-line password instead rename-command FLUSHDB "" rename-command FLUSHALL "" rename-command DEBUG "" -rename-command CONFIG "CONFIG_${REDIS_PASSWORD}" +rename-command CONFIG "CONFIG_DISABLED" # Memory management maxmemory 256mb @@ -28,7 +28,7 @@ tcp-keepalive 300 timeout 0 # Disable dangerous commands -rename-command SHUTDOWN "SHUTDOWN_${REDIS_PASSWORD}" +rename-command SHUTDOWN "SHUTDOWN_DISABLED" rename-command KEYS "" rename-command MONITOR "" rename-command SLAVEOF "" diff --git a/frontend/src/pages/Hosts.jsx b/frontend/src/pages/Hosts.jsx index 191ecc3..7a7dc73 100644 --- a/frontend/src/pages/Hosts.jsx +++ b/frontend/src/pages/Hosts.jsx @@ -505,8 +505,8 @@ const Hosts = () => { }, [hosts]); const bulkUpdateGroupMutation = useMutation({ - mutationFn: ({ hostIds, hostGroupId }) => - adminHostsAPI.bulkUpdateGroup(hostIds, hostGroupId), + mutationFn: ({ hostIds, groupIds }) => + adminHostsAPI.bulkUpdateGroups(hostIds, groupIds), onSuccess: (data) => { console.log("bulkUpdateGroupMutation success:", data); @@ -517,11 +517,7 @@ const Hosts = () => { return oldData.map((host) => { const updatedHost = data.hosts.find((h) => h.id === host.id); if (updatedHost) { - // Ensure hostGroupId is set correctly - return { - ...updatedHost, - hostGroupId: updatedHost.host_groups?.id || null, - }; + return updatedHost; } return host; }); @@ -671,8 +667,8 @@ const Hosts = () => { } }; - const handleBulkAssign = (hostGroupId) => { - bulkUpdateGroupMutation.mutate({ hostIds: selectedHosts, hostGroupId }); + const handleBulkAssign = (groupIds) => { + bulkUpdateGroupMutation.mutate({ hostIds: selectedHosts, groupIds }); }; const handleBulkDelete = () => { @@ -1797,8 +1793,7 @@ const BulkAssignModal = ({ onAssign, isLoading, }) => { - const [selectedGroupId, setSelectedGroupId] = useState(""); - const bulkHostGroupId = useId(); + const [selectedGroupIds, setSelectedGroupIds] = useState([]); // Fetch host groups for selection const { data: hostGroups } = useQuery({ @@ -1812,7 +1807,17 @@ const BulkAssignModal = ({ const handleSubmit = (e) => { e.preventDefault(); - onAssign(selectedGroupId || null); + onAssign(selectedGroupIds); + }; + + const toggleGroup = (groupId) => { + setSelectedGroupIds((prev) => { + if (prev.includes(groupId)) { + return prev.filter((id) => id !== groupId); + } else { + return [...prev, groupId]; + } + }); }; return ( @@ -1820,7 +1825,7 @@ const BulkAssignModal = ({

- Assign to Host Group + Assign to Host Groups

@@ -1884,7 +1905,7 @@ const BulkAssignModal = ({ Cancel
diff --git a/setup.sh b/setup.sh index 3a8619a..9d3531a 100755 --- a/setup.sh +++ b/setup.sh @@ -570,6 +570,20 @@ install_postgresql() { fi } +# Install Redis +install_redis() { + print_info "Installing Redis..." + + if systemctl is-active --quiet redis-server; then + print_status "Redis already running" + else + $PKG_INSTALL redis-server + systemctl start redis-server + systemctl enable redis-server + print_status "Redis installed and started" + fi +} + # Install nginx install_nginx() { print_info "Installing nginx..." @@ -858,6 +872,12 @@ AUTH_RATE_LIMIT_MAX=500 AGENT_RATE_LIMIT_WINDOW_MS=60000 AGENT_RATE_LIMIT_MAX=1000 +# Redis Configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 + # Logging LOG_LEVEL=info ENABLE_LOGGING=true @@ -1356,6 +1376,12 @@ Database Information: - Host: localhost - Port: 5432 +Redis Information: +- Host: localhost +- Port: 6379 +- Password: (none - Redis runs without authentication) +- Database: 0 + Networking: - Backend Port: $BACKEND_PORT - Nginx Config: /etc/nginx/sites-available/$FQDN @@ -1516,6 +1542,7 @@ deploy_instance() { # System setup (prerequisites already installed in interactive_setup) install_nodejs install_postgresql + install_redis install_nginx # Only install certbot if SSL is enabled