diff --git a/agents/patchmon-agent.sh b/agents/patchmon-agent.sh index 750ce51..43ab174 100755 --- a/agents/patchmon-agent.sh +++ b/agents/patchmon-agent.sh @@ -1,12 +1,12 @@ #!/bin/bash -# PatchMon Agent Script v1.2.8 +# PatchMon Agent Script v1.2.9 # This script sends package update information to the PatchMon server using API credentials # Configuration PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}" API_VERSION="v1" -AGENT_VERSION="1.2.8" +AGENT_VERSION="1.2.9" CONFIG_FILE="/etc/patchmon/agent.conf" CREDENTIALS_FILE="/etc/patchmon/credentials" LOG_FILE="/var/log/patchmon-agent.log" diff --git a/backend/package.json b/backend/package.json index 50700b0..0cd5fb5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "patchmon-backend", - "version": "1.2.8", + "version": "1.2.9", "description": "Backend API for Linux Patch Monitoring System", "license": "AGPL-3.0", "main": "src/server.js", diff --git a/backend/src/routes/gethomepageRoutes.js b/backend/src/routes/gethomepageRoutes.js new file mode 100644 index 0000000..cc44163 --- /dev/null +++ b/backend/src/routes/gethomepageRoutes.js @@ -0,0 +1,236 @@ +const express = require("express"); +const { createPrismaClient } = require("../config/database"); +const bcrypt = require("bcryptjs"); + +const router = express.Router(); +const prisma = createPrismaClient(); + +// Middleware to authenticate API key +const authenticateApiKey = async (req, res, next) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith("Basic ")) { + return res + .status(401) + .json({ error: "Missing or invalid authorization header" }); + } + + // Decode base64 credentials + const base64Credentials = authHeader.split(" ")[1]; + const credentials = Buffer.from(base64Credentials, "base64").toString( + "ascii", + ); + const [apiKey, apiSecret] = credentials.split(":"); + + if (!apiKey || !apiSecret) { + return res.status(401).json({ error: "Invalid credentials format" }); + } + + // Find the token in database + const token = await prisma.auto_enrollment_tokens.findUnique({ + where: { token_key: apiKey }, + include: { + users: { + select: { + id: true, + username: true, + role: true, + }, + }, + }, + }); + + if (!token) { + console.log(`API key not found: ${apiKey}`); + return res.status(401).json({ error: "Invalid API key" }); + } + + // Check if token is active + if (!token.is_active) { + return res.status(401).json({ error: "API key is disabled" }); + } + + // Check if token has expired + if (token.expires_at && new Date(token.expires_at) < new Date()) { + return res.status(401).json({ error: "API key has expired" }); + } + + // Check if token is for gethomepage integration + if (token.metadata?.integration_type !== "gethomepage") { + return res.status(401).json({ error: "Invalid API key type" }); + } + + // Verify the secret + const isValidSecret = await bcrypt.compare(apiSecret, token.token_secret); + if (!isValidSecret) { + return res.status(401).json({ error: "Invalid API secret" }); + } + + // Check IP restrictions if any + if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) { + const clientIp = req.ip || req.connection.remoteAddress; + const forwardedFor = req.headers["x-forwarded-for"]; + const realIp = req.headers["x-real-ip"]; + + // Get the actual client IP (considering proxies) + const actualClientIp = forwardedFor + ? forwardedFor.split(",")[0].trim() + : realIp || clientIp; + + const isAllowedIp = token.allowed_ip_ranges.some((range) => { + // Simple IP range check (can be enhanced for CIDR support) + return actualClientIp.startsWith(range) || actualClientIp === range; + }); + + if (!isAllowedIp) { + console.log( + `IP validation failed. Client IP: ${actualClientIp}, Allowed ranges: ${token.allowed_ip_ranges.join(", ")}`, + ); + return res.status(403).json({ error: "IP address not allowed" }); + } + } + + // Update last used timestamp + await prisma.auto_enrollment_tokens.update({ + where: { id: token.id }, + data: { last_used_at: new Date() }, + }); + + // Attach token info to request + req.apiToken = token; + next(); + } catch (error) { + console.error("API key authentication error:", error); + res.status(500).json({ error: "Authentication failed" }); + } +}; + +// Get homepage widget statistics +router.get("/stats", authenticateApiKey, async (_req, res) => { + try { + // Get total hosts count + const totalHosts = await prisma.hosts.count({ + where: { status: "active" }, + }); + + // Get total outdated packages count + const totalOutdatedPackages = await prisma.host_packages.count({ + where: { needs_update: true }, + }); + + // Get total repositories count + const totalRepos = await prisma.repositories.count({ + where: { is_active: true }, + }); + + // Get hosts that need updates (have outdated packages) + const hostsNeedingUpdates = await prisma.hosts.count({ + where: { + status: "active", + host_packages: { + some: { + needs_update: true, + }, + }, + }, + }); + + // Get security updates count + const securityUpdates = await prisma.host_packages.count({ + where: { + needs_update: true, + is_security_update: true, + }, + }); + + // Get hosts with security updates + const hostsWithSecurityUpdates = await prisma.hosts.count({ + where: { + status: "active", + host_packages: { + some: { + needs_update: true, + is_security_update: true, + }, + }, + }, + }); + + // Get up-to-date hosts count + const upToDateHosts = totalHosts - hostsNeedingUpdates; + + // Get recent update activity (last 24 hours) + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const recentUpdates = await prisma.update_history.count({ + where: { + timestamp: { + gte: oneDayAgo, + }, + status: "success", + }, + }); + + // Get OS distribution + const osDistribution = await prisma.hosts.groupBy({ + by: ["os_type"], + where: { status: "active" }, + _count: { + id: true, + }, + orderBy: { + _count: { + id: "desc", + }, + }, + }); + + // Format OS distribution data + const osDistributionFormatted = osDistribution.map((os) => ({ + name: os.os_type, + count: os._count.id, + })); + + // Extract top 3 OS types for flat display in widgets + const top_os_1 = osDistributionFormatted[0] || { name: "None", count: 0 }; + const top_os_2 = osDistributionFormatted[1] || { name: "None", count: 0 }; + const top_os_3 = osDistributionFormatted[2] || { name: "None", count: 0 }; + + // Prepare response data + const stats = { + total_hosts: totalHosts, + total_outdated_packages: totalOutdatedPackages, + total_repos: totalRepos, + hosts_needing_updates: hostsNeedingUpdates, + up_to_date_hosts: upToDateHosts, + security_updates: securityUpdates, + hosts_with_security_updates: hostsWithSecurityUpdates, + recent_updates_24h: recentUpdates, + os_distribution: osDistributionFormatted, + // Flattened OS data for easy widget display + top_os_1_name: top_os_1.name, + top_os_1_count: top_os_1.count, + top_os_2_name: top_os_2.name, + top_os_2_count: top_os_2.count, + top_os_3_name: top_os_3.name, + top_os_3_count: top_os_3.count, + last_updated: new Date().toISOString(), + }; + + res.json(stats); + } catch (error) { + console.error("Error fetching homepage stats:", error); + res.status(500).json({ error: "Failed to fetch statistics" }); + } +}); + +// Health check endpoint for the API +router.get("/health", authenticateApiKey, async (req, res) => { + res.json({ + status: "ok", + timestamp: new Date().toISOString(), + api_key: req.apiToken.token_name, + }); +}); + +module.exports = router; diff --git a/backend/src/routes/versionRoutes.js b/backend/src/routes/versionRoutes.js index 4cc557b..550f151 100644 --- a/backend/src/routes/versionRoutes.js +++ b/backend/src/routes/versionRoutes.js @@ -14,13 +14,13 @@ const router = express.Router(); function getCurrentVersion() { try { const packageJson = require("../../package.json"); - return packageJson?.version || "1.2.8"; + return packageJson?.version || "1.2.9"; } catch (packageError) { console.warn( "Could not read version from package.json, using fallback:", packageError.message, ); - return "1.2.8"; + return "1.2.9"; } } @@ -292,11 +292,11 @@ router.get( ) { console.log("GitHub API rate limited, providing fallback data"); latestRelease = { - tagName: "1.2.8", + tagName: "v1.2.8", version: "1.2.8", publishedAt: "2025-10-02T17:12:53Z", htmlUrl: - "https://github.com/PatchMon/PatchMon/releases/tag/1.2.8", + "https://github.com/PatchMon/PatchMon/releases/tag/v1.2.8", }; latestCommit = { sha: "cc89df161b8ea5d48ff95b0eb405fe69042052cd", @@ -318,10 +318,9 @@ router.get( latestRelease = settings.latest_version ? { version: settings.latest_version, - tagName: settings.latest_version, + tagName: `v${settings.latest_version}`, publishedAt: null, // Only use date from GitHub API, not cached data - // Note: URL may need 'v' prefix depending on actual tag format in repo - htmlUrl: `${githubRepoUrl.replace(/\.git$/, "")}/releases/tag/${settings.latest_version}`, + htmlUrl: `${githubRepoUrl.replace(/\.git$/, "")}/releases/tag/v${settings.latest_version}`, } : null; } diff --git a/backend/src/server.js b/backend/src/server.js index 58bcbbb..229b5fb 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -62,6 +62,7 @@ const versionRoutes = require("./routes/versionRoutes"); const tfaRoutes = require("./routes/tfaRoutes"); const searchRoutes = require("./routes/searchRoutes"); const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes"); +const gethomepageRoutes = require("./routes/gethomepageRoutes"); const updateScheduler = require("./services/updateScheduler"); const { initSettings } = require("./services/settingsService"); const { cleanup_expired_sessions } = require("./utils/session_manager"); @@ -422,6 +423,7 @@ app.use( authLimiter, autoEnrollmentRoutes, ); +app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes); // Error handling middleware app.use((err, _req, res, _next) => { diff --git a/backend/src/services/updateScheduler.js b/backend/src/services/updateScheduler.js index ab6f12d..0e717ea 100644 --- a/backend/src/services/updateScheduler.js +++ b/backend/src/services/updateScheduler.js @@ -104,7 +104,7 @@ class UpdateScheduler { } // Read version from package.json dynamically - let currentVersion = "1.2.8"; // fallback + let currentVersion = "1.2.9"; // fallback try { const packageJson = require("../../package.json"); if (packageJson?.version) { @@ -214,7 +214,7 @@ class UpdateScheduler { const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; // Get current version for User-Agent - let currentVersion = "1.2.8"; // fallback + let currentVersion = "1.2.9"; // fallback try { const packageJson = require("../../package.json"); if (packageJson?.version) { diff --git a/frontend/package.json b/frontend/package.json index 8c88948..3685593 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "patchmon-frontend", "private": true, - "version": "1.2.8", + "version": "1.2.9", "license": "AGPL-3.0", "type": "module", "scripts": { diff --git a/frontend/src/pages/Queue.jsx b/frontend/src/pages/Queue.jsx index cd09ac4..f73a58a 100644 --- a/frontend/src/pages/Queue.jsx +++ b/frontend/src/pages/Queue.jsx @@ -79,13 +79,13 @@ const Queue = () => { hostname: "web-server-01", ip: "192.168.1.100", type: "Agent Update Collection", - description: "Agent v1.2.7 → v1.2.8", + description: "Agent v1.2.8 → v1.2.9", status: "pending", priority: "medium", lastCommunication: "2024-01-15 10:00:00", nextExpectedCommunication: "2024-01-15 11:00:00", - currentVersion: "1.2.7", - targetVersion: "1.2.8", + currentVersion: "1.2.8", + targetVersion: "1.2.9", retryCount: 0, maxRetries: 5, }, @@ -99,8 +99,8 @@ const Queue = () => { priority: "high", lastCommunication: "2024-01-15 10:15:00", nextExpectedCommunication: "2024-01-15 11:15:00", - currentVersion: "1.2.8", - targetVersion: "1.2.8", + currentVersion: "1.2.9", + targetVersion: "1.2.9", retryCount: 0, maxRetries: 3, }, @@ -109,13 +109,13 @@ const Queue = () => { hostname: "app-server-03", ip: "192.168.1.102", type: "Agent Update Collection", - description: "Agent v1.2.6 → v1.2.8", + description: "Agent v1.2.7 → v1.2.9", status: "completed", priority: "low", lastCommunication: "2024-01-15 09:30:00", completedAt: "2024-01-15 09:45:00", - currentVersion: "1.2.8", - targetVersion: "1.2.8", + currentVersion: "1.2.9", + targetVersion: "1.2.9", retryCount: 0, maxRetries: 5, }, diff --git a/frontend/src/pages/settings/Integrations.jsx b/frontend/src/pages/settings/Integrations.jsx index 8732c12..75853ea 100644 --- a/frontend/src/pages/settings/Integrations.jsx +++ b/frontend/src/pages/settings/Integrations.jsx @@ -1,5 +1,6 @@ import { AlertCircle, + BookOpen, CheckCircle, Copy, Eye, @@ -9,11 +10,18 @@ import { Trash2, X, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useId, useState } from "react"; import SettingsLayout from "../../components/SettingsLayout"; import api from "../../utils/api"; const Integrations = () => { + // Generate unique IDs for form elements + const token_name_id = useId(); + const token_key_id = useId(); + const token_secret_id = useId(); + const token_base64_id = useId(); + const gethomepage_config_id = useId(); + const [activeTab, setActiveTab] = useState("proxmox"); const [tokens, setTokens] = useState([]); const [host_groups, setHostGroups] = useState([]); @@ -94,7 +102,8 @@ const Integrations = () => { ? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim()) : [], metadata: { - integration_type: "proxmox-lxc", + integration_type: + activeTab === "gethomepage" ? "gethomepage" : "proxmox-lxc", }, }; @@ -158,12 +167,49 @@ const Integrations = () => { } }; - const copy_to_clipboard = (text, key) => { - navigator.clipboard.writeText(text); - setCopySuccess({ ...copy_success, [key]: true }); - setTimeout(() => { - setCopySuccess({ ...copy_success, [key]: false }); - }, 2000); + const copy_to_clipboard = async (text, key) => { + // Check if Clipboard API is available + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + setCopySuccess({ ...copy_success, [key]: true }); + setTimeout(() => { + setCopySuccess({ ...copy_success, [key]: false }); + }, 2000); + return; + } catch (error) { + console.error("Clipboard API failed:", error); + // Fall through to fallback method + } + } + + // Fallback method for older browsers or non-secure contexts + try { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + textArea.style.top = "-999999px"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const successful = document.execCommand("copy"); + document.body.removeChild(textArea); + + if (successful) { + setCopySuccess({ ...copy_success, [key]: true }); + setTimeout(() => { + setCopySuccess({ ...copy_success, [key]: false }); + }, 2000); + } else { + console.error("Fallback copy failed"); + alert("Failed to copy to clipboard. Please copy manually."); + } + } catch (fallbackError) { + console.error("Fallback copy failed:", fallbackError); + alert("Failed to copy to clipboard. Please copy manually."); + } }; const format_date = (date_string) => { @@ -198,6 +244,17 @@ const Integrations = () => { > Proxmox LXC + {/* Future tabs can be added here */} @@ -367,9 +424,20 @@ const Integrations = () => { {/* Documentation Section */}
-

- How to Use Auto-Enrollment -

+
+

+ How to Use Auto-Enrollment +

+ + + Documentation + +
  1. Create a new auto-enrollment token using the button above @@ -395,6 +463,266 @@ const Integrations = () => {
)} + + {/* GetHomepage Tab */} + {activeTab === "gethomepage" && ( +
+ {/* Header with New API Key Button */} +
+
+
+ +
+
+

+ GetHomepage Widget Integration +

+

+ Create API keys to display PatchMon statistics in your + GetHomepage dashboard +

+
+
+ +
+ + {/* API Keys List */} + {loading ? ( +
+
+
+ ) : tokens.filter( + (token) => + token.metadata?.integration_type === "gethomepage", + ).length === 0 ? ( +
+

No GetHomepage API keys created yet.

+

+ Create an API key to enable GetHomepage widget + integration. +

+
+ ) : ( +
+ {tokens + .filter( + (token) => + token.metadata?.integration_type === "gethomepage", + ) + .map((token) => ( +
+
+
+
+

+ {token.token_name} +

+ + GetHomepage + + {token.is_active ? ( + + Active + + ) : ( + + Inactive + + )} +
+
+
+ + {token.token_key} + + +
+

Created: {format_date(token.created_at)}

+ {token.last_used_at && ( +

+ Last Used: {format_date(token.last_used_at)} +

+ )} + {token.expires_at && ( +

+ Expires: {format_date(token.expires_at)} + {new Date(token.expires_at) < + new Date() && ( + + (Expired) + + )} +

+ )} +
+
+
+ + +
+
+
+ ))} +
+ )} + + {/* Documentation Section */} +
+
+

+ How to Use GetHomepage Integration +

+ + + Documentation + +
+
    +
  1. Create a new API key using the button above
  2. +
  3. Copy the API key and secret from the success dialog
  4. +
  5. + Add the following widget configuration to your GetHomepage{" "} + + services.yml + {" "} + file: +
  6. +
+ +
+
+											{`- PatchMon:
+    href: ${server_url}
+    description: PatchMon Statistics
+    icon: ${server_url}/assets/favicon.svg
+    widget:
+      type: customapi
+      url: ${server_url}/api/v1/gethomepage/stats
+      headers:
+        Authorization: Basic BASE64_ENCODED_CREDENTIALS
+      mappings:
+        - field: total_hosts
+          label: Total Hosts
+        - field: hosts_needing_updates
+          label: Needs Updates
+        - field: security_updates
+          label: Security Updates`}
+										
+
+ +
+

+ + How to generate BASE64_ENCODED_CREDENTIALS: + +

+
+											{`echo -n "YOUR_API_KEY:YOUR_API_SECRET" | base64`}
+										
+

+ Replace YOUR_API_KEY and YOUR_API_SECRET with your actual + credentials, then run this command to get the base64 + string. +

+
+ +
+

+ Additional Widget Examples +

+

+ You can create multiple widgets to display different + statistics: +

+
+
+ Security Updates Widget: +
+ type: customapi +
+ key: security_updates +
+ value: hosts_with_security_updates +
+ label: Security Updates +
+
+ Up-to-Date Hosts Widget: +
+ type: customapi +
+ key: up_to_date_hosts +
+ value: total_hosts +
+ label: Up-to-Date Hosts +
+
+ Recent Activity Widget: +
+ type: customapi +
+ key: recent_updates_24h +
+ value: total_hosts +
+ label: Updates (24h) +
+
+
+
+
+ )}
@@ -406,7 +734,9 @@ const Integrations = () => {

- Create Auto-Enrollment Token + {activeTab === "gethomepage" + ? "Create GetHomepage API Key" + : "Create Auto-Enrollment Token"}

-
-
- -
-
- Token Secret -
-
- - - -
-
- -
-
- One-Line Installation Command -
-

- Run this command on your Proxmox host to download and - execute the enrollment script: -

- - {/* Force Install Toggle */} -
- -

- Enable this if your LXC containers have broken packages - (CloudPanel, WHM, etc.) that block apt-get operations -

-
- -
- - -
-

- 💡 This command will automatically discover and enroll all - running LXC containers. -

-
- - -
+
+ +
+
+ +

+ Important: Save these credentials - the + secret won't be shown again. +

+
+
+ +
+
+ + +
+ +
+
+ +
+ + +
+
+ +
+ +
+ + + +
+
+
+ + {activeTab === "proxmox" && ( +
+
+ One-Line Installation Command +
+

+ Run this command on your Proxmox host to download and + execute the enrollment script: +

+ + {/* Force Install Toggle */} +
+ +

+ Enable this if your LXC containers have broken packages + (CloudPanel, WHM, etc.) that block apt-get operations +

+
+ +
+ + +
+

+ 💡 This command will automatically discover and enroll all + running LXC containers. +

+
+ )} + + {activeTab === "gethomepage" && ( +
+
+ +
+ + +
+
+ +
+
+ + +
+