Compare commits

..

33 Commits

Author SHA1 Message Date
9 Technology Group LTD
19f8f85d38 Merge pull request #309 from jaas666/qol/remove-arch-selection
Remove architecture selection from agent installation UI
2025-11-17 01:35:31 +00:00
9 Technology Group LTD
2b7aeda980 Merge pull request #321 from PatchMon/feature/alpine
ui improvements on the toggle
2025-11-17 01:23:24 +00:00
Muhammad Ibrahim
d2b36f36c9 ui improvements on the toggle 2025-11-17 01:18:20 +00:00
9 Technology Group LTD
b02ed6aa3b Merge pull request #320 from PatchMon/feature/alpine
added reboot required flag
2025-11-16 22:54:15 +00:00
Muhammad Ibrahim
539bbb7fbc added reboot required flag
added kernel running/installed version
Added dasboard card and filteres for reboot
fixed security qty updated on dnf
2025-11-16 22:50:41 +00:00
9 Technology Group LTD
3a6f04f748 Merge pull request #319 from PatchMon/feature/alpine
updated agent files
2025-11-16 19:03:27 +00:00
Muhammad Ibrahim
8df6ca2342 updated agent files
Fixed removal script
Fixed agent version checking
2025-11-16 19:00:10 +00:00
9 Technology Group LTD
0e8d74e821 Merge pull request #318 from PatchMon/feature/alpine
openssl alpine binary for Docker
2025-11-15 01:04:44 +00:00
Muhammad Ibrahim
0dbcc3c2c3 openssl alpine binary for Docker 2025-11-15 01:03:43 +00:00
9 Technology Group LTD
ab23eaf7bd Merge pull request #317 from PatchMon/feature/alpine
fixed node permissions
2025-11-15 00:54:29 +00:00
Muhammad Ibrahim
bfc1cc3bf0 fixed node permissions 2025-11-15 00:53:37 +00:00
9 Technology Group LTD
d33992b5f7 Merge pull request #316 from PatchMon/feature/alpine
adding ssl
2025-11-15 00:46:00 +00:00
Muhammad Ibrahim
3e5af312b6 adding ssl 2025-11-15 00:34:19 +00:00
9 Technology Group LTD
823b89f2c1 Merge pull request #315 from PatchMon/feature/alpine
arm builder
2025-11-15 00:30:43 +00:00
Muhammad Ibrahim
84cdc7224f arm builder 2025-11-15 00:24:08 +00:00
9 Technology Group LTD
c770bf1444 API for auto-enrollment
Api
2025-11-15 00:08:37 +00:00
Muhammad Ibrahim
307970ebd4 Fixed formatting 2025-11-15 00:03:02 +00:00
Muhammad Ibrahim
9da341f84c auto-enrolment enhancements 2025-11-14 23:57:43 +00:00
Muhammad Ibrahim
1ca8bf8581 better auto-enrollment system 2025-11-14 22:53:48 +00:00
Muhammad Ibrahim
a4bc9c4aed workspace fix 2025-11-14 21:00:55 +00:00
Muhammad Ibrahim
8f25bc5b8b fixed docker build 2025-11-14 20:54:43 +00:00
Muhammad Ibrahim
a37b479de6 1.3.4 2025-11-14 20:45:40 +00:00
Juan A.
8cf29c9bbb remove-architecture-selection 2025-11-13 19:28:11 -06:00
9 Technology Group LTD
3f18074f01 Merge pull request #304 from PatchMon/feature/alpine
new binary for alpie apk support
2025-11-11 12:49:09 +00:00
Muhammad Ibrahim
ab700a3bc8 new binary for alpie apk support 2025-11-11 12:42:00 +00:00
9 Technology Group LTD
9857d7cdfc Merge pull request #302 from PatchMon/feature/api
added the migration file
2025-11-10 22:02:37 +00:00
Muhammad Ibrahim
3f6466c80a added the migration file 2025-11-10 22:00:02 +00:00
9 Technology Group LTD
d7d47089b2 Merge pull request #301 from PatchMon/feature/api
Feature/api
2025-11-10 20:41:36 +00:00
Muhammad Ibrahim
d1069a8bd0 api endpoint and scopes created 2025-11-10 20:34:03 +00:00
Muhammad Ibrahim
bedcd1ac73 added api scope creator 2025-11-10 20:32:40 +00:00
Muhammad Ibrahim
f0b028cb77 alpine support on the agent installation script 2025-11-08 22:00:34 +00:00
9 Technology Group LTD
427743b81e Merge pull request #294 from PatchMon/feature/alpine
alpine support (apk) support agents
2025-11-08 21:26:04 +00:00
Muhammad Ibrahim
8c2d4aa42b alpine support (apk) support agents 2025-11-08 21:15:08 +00:00
52 changed files with 2929 additions and 1193 deletions

View File

@@ -43,6 +43,8 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: linux/arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -74,3 +76,4 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.image }}
cache-to: type=gha,mode=max,scope=${{ matrix.image }}
provenance: false

270
agents/direct_host_auto_enroll.sh Executable file
View File

@@ -0,0 +1,270 @@
#!/bin/sh
# PatchMon Direct Host Auto-Enrollment Script
# POSIX-compliant shell script (works with dash, ash, bash, etc.)
# Usage: curl -s "https://patchmon.example.com/api/v1/auto-enrollment/script?type=direct-host&token_key=KEY&token_secret=SECRET" | sh
set -e
SCRIPT_VERSION="1.0.0"
# =============================================================================
# PatchMon Direct Host Auto-Enrollment Script
# =============================================================================
# This script automatically enrolls the current host into PatchMon for patch
# management.
#
# Usage:
# curl -s "https://patchmon.example.com/api/v1/auto-enrollment/script?type=direct-host&token_key=KEY&token_secret=SECRET" | sh
#
# With custom friendly name:
# curl -s "https://patchmon.example.com/api/v1/auto-enrollment/script?type=direct-host&token_key=KEY&token_secret=SECRET" | FRIENDLY_NAME="My Server" sh
#
# Requirements:
# - Run as root or with sudo
# - Auto-enrollment token from PatchMon
# - Network access to PatchMon server
# =============================================================================
# ===== CONFIGURATION =====
PATCHMON_URL="${PATCHMON_URL:-https://patchmon.example.com}"
AUTO_ENROLLMENT_KEY="${AUTO_ENROLLMENT_KEY:-}"
AUTO_ENROLLMENT_SECRET="${AUTO_ENROLLMENT_SECRET:-}"
CURL_FLAGS="${CURL_FLAGS:--s}"
FORCE_INSTALL="${FORCE_INSTALL:-false}"
FRIENDLY_NAME="${FRIENDLY_NAME:-}" # Optional: Custom friendly name for the host
# ===== COLOR OUTPUT =====
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# ===== LOGGING FUNCTIONS =====
info() { printf "%b\n" "${GREEN}[INFO]${NC} $1"; }
warn() { printf "%b\n" "${YELLOW}[WARN]${NC} $1"; }
error() { printf "%b\n" "${RED}[ERROR]${NC} $1" >&2; exit 1; }
success() { printf "%b\n" "${GREEN}[SUCCESS]${NC} $1"; }
debug() { [ "${DEBUG:-false}" = "true" ] && printf "%b\n" "${BLUE}[DEBUG]${NC} $1" || true; }
# ===== BANNER =====
cat << "EOF"
╔═══════════════════════════════════════════════════════════════╗
║ ║
║ ____ _ _ __ __ ║
| _ \ __ _| |_ ___| |__ | \/ | ___ _ __ ║
| |_) / _` | __/ __| '_ \| |\/| |/ _ \| '_ \
| __/ (_| | || (__| | | | | | | (_) | | | |
|_| \__,_|\__\___|_| |_|_| |_|\___/|_| |_|
║ ║
║ Direct Host Auto-Enrollment Script ║
║ ║
╚═══════════════════════════════════════════════════════════════╝
EOF
echo ""
# ===== VALIDATION =====
info "Validating configuration..."
if [ -z "$AUTO_ENROLLMENT_KEY" ] || [ -z "$AUTO_ENROLLMENT_SECRET" ]; then
error "AUTO_ENROLLMENT_KEY and AUTO_ENROLLMENT_SECRET must be set"
fi
if [ -z "$PATCHMON_URL" ]; then
error "PATCHMON_URL must be set"
fi
# Check if running as root
if [ "$(id -u)" -ne 0 ]; then
error "This script must be run as root (use sudo)"
fi
# Check for required commands
for cmd in curl; do
if ! command -v $cmd >/dev/null 2>&1; then
error "Required command '$cmd' not found. Please install it first."
fi
done
info "Configuration validated successfully"
info "PatchMon Server: $PATCHMON_URL"
echo ""
# ===== GATHER HOST INFORMATION =====
info "Gathering host information..."
# Get hostname
hostname=$(hostname)
# Use FRIENDLY_NAME env var if provided, otherwise use hostname
if [ -n "$FRIENDLY_NAME" ]; then
friendly_name="$FRIENDLY_NAME"
info "Using custom friendly name: $friendly_name"
else
friendly_name="$hostname"
fi
# Try to get machine_id (optional, for tracking)
machine_id=""
if [ -f /etc/machine-id ]; then
machine_id=$(cat /etc/machine-id 2>/dev/null || echo "")
elif [ -f /var/lib/dbus/machine-id ]; then
machine_id=$(cat /var/lib/dbus/machine-id 2>/dev/null || echo "")
fi
# Get OS information
os_info="unknown"
if [ -f /etc/os-release ]; then
os_info=$(grep "^PRETTY_NAME=" /etc/os-release 2>/dev/null | cut -d'"' -f2 || echo "unknown")
fi
# Get IP address (first non-loopback)
ip_address=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown")
# Detect architecture
arch_raw=$(uname -m 2>/dev/null || echo "unknown")
case "$arch_raw" in
"x86_64")
architecture="amd64"
;;
"i386"|"i686")
architecture="386"
;;
"aarch64"|"arm64")
architecture="arm64"
;;
"armv7l"|"armv6l"|"arm")
architecture="arm"
;;
*)
warn " ⚠ Unknown architecture '$arch_raw', defaulting to amd64"
architecture="amd64"
;;
esac
info "Hostname: $hostname"
info "Friendly Name: $friendly_name"
info "IP Address: $ip_address"
info "OS: $os_info"
info "Architecture: $architecture"
if [ -n "$machine_id" ]; then
# POSIX-compliant substring (first 16 chars)
machine_id_short=$(printf "%.16s" "$machine_id")
info "Machine ID: ${machine_id_short}..."
else
info "Machine ID: (not available)"
fi
echo ""
# ===== CHECK IF AGENT ALREADY INSTALLED =====
info "Checking if agent is already configured..."
config_check=$(sh -c "
if [ -f /etc/patchmon/config.yml ] && [ -f /etc/patchmon/credentials.yml ]; then
if [ -f /usr/local/bin/patchmon-agent ]; then
# Try to ping using existing configuration
if /usr/local/bin/patchmon-agent ping >/dev/null 2>&1; then
echo 'ping_success'
else
echo 'ping_failed'
fi
else
echo 'binary_missing'
fi
else
echo 'not_configured'
fi
" 2>/dev/null || echo "error")
if [ "$config_check" = "ping_success" ]; then
success "Host already enrolled and agent ping successful - nothing to do"
exit 0
elif [ "$config_check" = "ping_failed" ]; then
warn "Agent configuration exists but ping failed - will reinstall"
elif [ "$config_check" = "binary_missing" ]; then
warn "Config exists but agent binary missing - will reinstall"
elif [ "$config_check" = "not_configured" ]; then
info "Agent not yet configured - proceeding with enrollment"
else
warn "Could not check agent status - proceeding with enrollment"
fi
echo ""
# ===== ENROLL HOST =====
info "Enrolling $friendly_name in PatchMon..."
# Build JSON payload
json_payload=$(cat <<EOF
{
"friendly_name": "$friendly_name",
"metadata": {
"hostname": "$hostname",
"ip_address": "$ip_address",
"os_info": "$os_info",
"architecture": "$architecture"
}
}
EOF
)
# Add machine_id if available
if [ -n "$machine_id" ]; then
json_payload=$(echo "$json_payload" | sed "s/\"friendly_name\"/\"machine_id\": \"$machine_id\",\n \"friendly_name\"/")
fi
response=$(curl $CURL_FLAGS -X POST \
-H "X-Auto-Enrollment-Key: $AUTO_ENROLLMENT_KEY" \
-H "X-Auto-Enrollment-Secret: $AUTO_ENROLLMENT_SECRET" \
-H "Content-Type: application/json" \
-d "$json_payload" \
"$PATCHMON_URL/api/v1/auto-enrollment/enroll" \
-w "\n%{http_code}" 2>&1)
http_code=$(echo "$response" | tail -n 1)
body=$(echo "$response" | sed '$d')
if [ "$http_code" = "201" ]; then
# Use grep and cut instead of jq since jq may not be installed
api_id=$(echo "$body" | grep -o '"api_id":"[^"]*' | cut -d'"' -f4 || echo "")
api_key=$(echo "$body" | grep -o '"api_key":"[^"]*' | cut -d'"' -f4 || echo "")
if [ -z "$api_id" ] || [ -z "$api_key" ]; then
error "Failed to parse API credentials from response"
fi
success "Host enrolled successfully: $api_id"
echo ""
# ===== INSTALL AGENT =====
info "Installing PatchMon agent..."
# Build install URL with force flag and architecture
install_url="$PATCHMON_URL/api/v1/hosts/install?arch=$architecture"
if [ "$FORCE_INSTALL" = "true" ]; then
install_url="$install_url&force=true"
info "Using force mode - will bypass broken packages"
fi
info "Using architecture: $architecture"
# Download and execute installation script
install_exit_code=0
install_output=$(curl $CURL_FLAGS \
-H "X-API-ID: $api_id" \
-H "X-API-KEY: $api_key" \
"$install_url" | sh 2>&1) || install_exit_code=$?
# Check both exit code AND success message in output
if [ "$install_exit_code" -eq 0 ] || echo "$install_output" | grep -q "PatchMon Agent installation completed successfully"; then
success "Agent installed successfully"
else
error "Failed to install agent (exit: $install_exit_code)"
fi
else
printf "%b\n" "${RED}[ERROR]${NC} Failed to enroll $friendly_name - HTTP $http_code" >&2
printf "%b\n" "Response: $body" >&2
exit 1
fi
echo ""
success "Auto-enrollment complete!"
exit 0

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,7 +1,7 @@
#!/bin/bash
#!/bin/sh
# PatchMon Agent Installation Script
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/install -H "X-API-ID: {API_ID}" -H "X-API-KEY: {API_KEY}" | bash
# POSIX-compliant shell script (works with dash, ash, bash, etc.)
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/install -H "X-API-ID: {API_ID}" -H "X-API-KEY: {API_KEY}" | sh
set -e
@@ -19,65 +19,69 @@ NC='\033[0m' # No Color
# Functions
error() {
echo -e "${RED}ERROR: $1${NC}" >&2
printf "%b\n" "${RED}ERROR: $1${NC}" >&2
exit 1
}
info() {
echo -e "${BLUE} $1${NC}"
printf "%b\n" "${BLUE}INFO: $1${NC}"
}
success() {
echo -e "${GREEN} $1${NC}"
printf "%b\n" "${GREEN}SUCCESS: $1${NC}"
}
warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
printf "%b\n" "${YELLOW}WARNING: $1${NC}"
}
# Check if running as root
if [[ $EUID -ne 0 ]]; then
if [ "$(id -u)" -ne 0 ]; then
error "This script must be run as root (use sudo)"
fi
# Verify system datetime and timezone
verify_datetime() {
info "🕐 Verifying system datetime and timezone..."
info "Verifying system datetime and timezone..."
# Get current system time
local system_time=$(date)
local timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
system_time=$(date)
timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
# Display current datetime info
echo ""
echo -e "${BLUE}📅 Current System Date/Time:${NC}"
printf "%b\n" "${BLUE}Current System Date/Time:${NC}"
echo " • Date/Time: $system_time"
echo " • Timezone: $timezone"
echo ""
# Check if we can read from stdin (interactive terminal)
if [[ -t 0 ]]; then
if [ -t 0 ]; then
# Interactive terminal - ask user
read -p "Does this date/time look correct to you? (y/N): " -r response
if [[ "$response" =~ ^[Yy]$ ]]; then
success "✅ Date/time verification passed"
printf "Does this date/time look correct to you? (y/N): "
read -r response
case "$response" in
[Yy]*)
success "Date/time verification passed"
echo ""
return 0
;;
*)
echo ""
return 0
else
printf "%b\n" "${RED}Date/time verification failed${NC}"
echo ""
echo -e "${RED}❌ Date/time verification failed${NC}"
echo ""
echo -e "${YELLOW}💡 Please fix the date/time and re-run the installation script:${NC}"
printf "%b\n" "${YELLOW}Please fix the date/time and re-run the installation script:${NC}"
echo " sudo timedatectl set-time 'YYYY-MM-DD HH:MM:SS'"
echo " sudo timedatectl set-timezone 'America/New_York' # or your timezone"
echo " sudo timedatectl list-timezones # to see available timezones"
echo ""
echo -e "${BLUE} After fixing the date/time, re-run this installation script.${NC}"
error "Installation cancelled - please fix date/time and re-run"
fi
printf "%b\n" "${BLUE}After fixing the date/time, re-run this installation script.${NC}"
error "Installation cancelled - please fix date/time and re-run"
;;
esac
else
# Non-interactive (piped from curl) - show warning and continue
echo -e "${YELLOW}⚠️ Non-interactive installation detected${NC}"
printf "%b\n" "${YELLOW}Non-interactive installation detected${NC}"
echo ""
echo "Please verify the date/time shown above is correct."
echo "If the date/time is incorrect, it may cause issues with:"
@@ -85,8 +89,8 @@ verify_datetime() {
echo " • Scheduled updates"
echo " • Data synchronization"
echo ""
echo -e "${GREEN}Continuing with installation...${NC}"
success "Date/time verification completed (assumed correct)"
printf "%b\n" "${GREEN}Continuing with installation...${NC}"
success "Date/time verification completed (assumed correct)"
echo ""
fi
}
@@ -121,9 +125,9 @@ cleanup_old_files
# Generate or retrieve machine ID
get_machine_id() {
# Try multiple sources for machine ID
if [[ -f /etc/machine-id ]]; then
if [ -f /etc/machine-id ]; then
cat /etc/machine-id
elif [[ -f /var/lib/dbus/machine-id ]]; then
elif [ -f /var/lib/dbus/machine-id ]; then
cat /var/lib/dbus/machine-id
else
# Fallback: generate from hardware info (less ideal but works)
@@ -132,12 +136,12 @@ get_machine_id() {
}
# Parse arguments from environment (passed via HTTP headers)
if [[ -z "$PATCHMON_URL" ]] || [[ -z "$API_ID" ]] || [[ -z "$API_KEY" ]]; then
if [ -z "$PATCHMON_URL" ] || [ -z "$API_ID" ] || [ -z "$API_KEY" ]; then
error "Missing required parameters. This script should be called via the PatchMon web interface."
fi
# Auto-detect architecture if not explicitly set
if [[ -z "$ARCHITECTURE" ]]; then
if [ -z "$ARCHITECTURE" ]; then
arch_raw=$(uname -m 2>/dev/null || echo "unknown")
# Map architecture to supported values
@@ -155,46 +159,39 @@ if [[ -z "$ARCHITECTURE" ]]; then
ARCHITECTURE="arm"
;;
*)
warning "⚠️ Unknown architecture '$arch_raw', defaulting to amd64"
warning "Unknown architecture '$arch_raw', defaulting to amd64"
ARCHITECTURE="amd64"
;;
esac
fi
# Validate architecture
if [[ "$ARCHITECTURE" != "amd64" && "$ARCHITECTURE" != "386" && "$ARCHITECTURE" != "arm64" && "$ARCHITECTURE" != "arm" ]]; then
if [ "$ARCHITECTURE" != "amd64" ] && [ "$ARCHITECTURE" != "386" ] && [ "$ARCHITECTURE" != "arm64" ] && [ "$ARCHITECTURE" != "arm" ]; then
error "Invalid architecture '$ARCHITECTURE'. Must be one of: amd64, 386, arm64, arm"
fi
# Check if --force flag is set (for bypassing broken packages)
FORCE_INSTALL="${FORCE_INSTALL:-false}"
if [[ "$*" == *"--force"* ]] || [[ "$FORCE_INSTALL" == "true" ]]; then
case "$*" in
*"--force"*) FORCE_INSTALL="true" ;;
esac
if [ "$FORCE_INSTALL" = "true" ]; then
FORCE_INSTALL="true"
warning "⚠️ Force mode enabled - will bypass broken packages"
warning "Force mode enabled - will bypass broken packages"
fi
# Get unique machine ID for this host
MACHINE_ID=$(get_machine_id)
export MACHINE_ID
info "🚀 Starting PatchMon Agent Installation..."
info "📋 Server: $PATCHMON_URL"
info "🔑 API ID: ${API_ID:0:16}..."
info "🆔 Machine ID: ${MACHINE_ID:0:16}..."
info "🏗️ Architecture: $ARCHITECTURE"
# Display diagnostic information
echo ""
echo -e "${BLUE}🔧 Installation Diagnostics:${NC}"
echo " • URL: $PATCHMON_URL"
echo " • CURL FLAGS: $CURL_FLAGS"
echo " • API ID: ${API_ID:0:16}..."
echo " • API Key: ${API_KEY:0:16}..."
echo " • Architecture: $ARCHITECTURE"
echo ""
info "Starting PatchMon Agent Installation..."
info "Server: $PATCHMON_URL"
info "API ID: $(echo "$API_ID" | cut -c1-16)..."
info "Machine ID: $(echo "$MACHINE_ID" | cut -c1-16)..."
info "Architecture: $ARCHITECTURE"
# Install required dependencies
info "📦 Installing required dependencies..."
info "Installing required dependencies..."
echo ""
# Function to check if a command exists
@@ -204,52 +201,56 @@ command_exists() {
# Function to install packages with error handling
install_apt_packages() {
local packages=("$@")
local missing_packages=()
# Space-separated list of packages
_packages="$*"
_missing_packages=""
# Check which packages are missing
for pkg in "${packages[@]}"; do
for pkg in $_packages; do
if ! command_exists "$pkg"; then
missing_packages+=("$pkg")
_missing_packages="$_missing_packages $pkg"
fi
done
if [ ${#missing_packages[@]} -eq 0 ]; then
# Trim leading space
_missing_packages=$(echo "$_missing_packages" | sed 's/^ //')
if [ -z "$_missing_packages" ]; then
success "All required packages are already installed"
return 0
fi
info "Need to install: ${missing_packages[*]}"
info "Need to install: $_missing_packages"
# Build apt-get command based on force mode
local apt_cmd="apt-get install ${missing_packages[*]} -y"
_apt_cmd="apt-get install $_missing_packages -y"
if [[ "$FORCE_INSTALL" == "true" ]]; then
if [ "$FORCE_INSTALL" = "true" ]; then
info "Using force mode - bypassing broken packages..."
apt_cmd="$apt_cmd -o APT::Get::Fix-Broken=false -o DPkg::Options::=\"--force-confold\" -o DPkg::Options::=\"--force-confdef\""
_apt_cmd="$_apt_cmd -o APT::Get::Fix-Broken=false -o DPkg::Options::=\"--force-confold\" -o DPkg::Options::=\"--force-confdef\""
fi
# Try to install packages
if eval "$apt_cmd" 2>&1 | tee /tmp/patchmon_apt_install.log; then
if eval "$_apt_cmd" 2>&1 | tee /tmp/patchmon_apt_install.log; then
success "Packages installed successfully"
return 0
else
warning "Package installation encountered issues, checking if required tools are available..."
# Verify critical dependencies are actually available
local all_ok=true
for pkg in "${packages[@]}"; do
_all_ok=true
for pkg in $_packages; do
if ! command_exists "$pkg"; then
if [[ "$FORCE_INSTALL" == "true" ]]; then
if [ "$FORCE_INSTALL" = "true" ]; then
error "Critical dependency '$pkg' is not available even with --force. Please install manually."
else
error "Critical dependency '$pkg' is not available. Try again with --force flag or install manually: apt-get install $pkg"
fi
all_ok=false
_all_ok=false
fi
done
if $all_ok; then
if $_all_ok; then
success "All required tools are available despite installation warnings"
return 0
else
@@ -260,121 +261,133 @@ install_apt_packages() {
# Function to check and install packages for yum/dnf
install_yum_dnf_packages() {
local pkg_manager="$1"
_pkg_manager="$1"
shift
local packages=("$@")
local missing_packages=()
_packages="$*"
_missing_packages=""
# Check which packages are missing
for pkg in "${packages[@]}"; do
for pkg in $_packages; do
if ! command_exists "$pkg"; then
missing_packages+=("$pkg")
_missing_packages="$_missing_packages $pkg"
fi
done
if [ ${#missing_packages[@]} -eq 0 ]; then
# Trim leading space
_missing_packages=$(echo "$_missing_packages" | sed 's/^ //')
if [ -z "$_missing_packages" ]; then
success "All required packages are already installed"
return 0
fi
info "Need to install: ${missing_packages[*]}"
info "Need to install: $_missing_packages"
if [[ "$pkg_manager" == "yum" ]]; then
yum install -y "${missing_packages[@]}"
if [ "$_pkg_manager" = "yum" ]; then
yum install -y $_missing_packages
else
dnf install -y "${missing_packages[@]}"
dnf install -y $_missing_packages
fi
}
# Function to check and install packages for zypper
install_zypper_packages() {
local packages=("$@")
local missing_packages=()
_packages="$*"
_missing_packages=""
# Check which packages are missing
for pkg in "${packages[@]}"; do
for pkg in $_packages; do
if ! command_exists "$pkg"; then
missing_packages+=("$pkg")
_missing_packages="$_missing_packages $pkg"
fi
done
if [ ${#missing_packages[@]} -eq 0 ]; then
# Trim leading space
_missing_packages=$(echo "$_missing_packages" | sed 's/^ //')
if [ -z "$_missing_packages" ]; then
success "All required packages are already installed"
return 0
fi
info "Need to install: ${missing_packages[*]}"
zypper install -y "${missing_packages[@]}"
info "Need to install: $_missing_packages"
zypper install -y $_missing_packages
}
# Function to check and install packages for pacman
install_pacman_packages() {
local packages=("$@")
local missing_packages=()
_packages="$*"
_missing_packages=""
# Check which packages are missing
for pkg in "${packages[@]}"; do
for pkg in $_packages; do
if ! command_exists "$pkg"; then
missing_packages+=("$pkg")
_missing_packages="$_missing_packages $pkg"
fi
done
if [ ${#missing_packages[@]} -eq 0 ]; then
# Trim leading space
_missing_packages=$(echo "$_missing_packages" | sed 's/^ //')
if [ -z "$_missing_packages" ]; then
success "All required packages are already installed"
return 0
fi
info "Need to install: ${missing_packages[*]}"
pacman -S --noconfirm "${missing_packages[@]}"
info "Need to install: $_missing_packages"
pacman -S --noconfirm $_missing_packages
}
# Function to check and install packages for apk
install_apk_packages() {
local packages=("$@")
local missing_packages=()
_packages="$*"
_missing_packages=""
# Check which packages are missing
for pkg in "${packages[@]}"; do
for pkg in $_packages; do
if ! command_exists "$pkg"; then
missing_packages+=("$pkg")
_missing_packages="$_missing_packages $pkg"
fi
done
if [ ${#missing_packages[@]} -eq 0 ]; then
# Trim leading space
_missing_packages=$(echo "$_missing_packages" | sed 's/^ //')
if [ -z "$_missing_packages" ]; then
success "All required packages are already installed"
return 0
fi
info "Need to install: ${missing_packages[*]}"
info "Need to install: $_missing_packages"
# Update package index before installation
info "Updating package index..."
apk update -q || true
# Build apk command
local apk_cmd="apk add --no-cache ${missing_packages[*]}"
_apk_cmd="apk add --no-cache $_missing_packages"
# Try to install packages
if eval "$apk_cmd" 2>&1 | tee /tmp/patchmon_apk_install.log; then
if eval "$_apk_cmd" 2>&1 | tee /tmp/patchmon_apk_install.log; then
success "Packages installed successfully"
return 0
else
warning "Package installation encountered issues, checking if required tools are available..."
# Verify critical dependencies are actually available
local all_ok=true
for pkg in "${packages[@]}"; do
_all_ok=true
for pkg in $_packages; do
if ! command_exists "$pkg"; then
if [[ "$FORCE_INSTALL" == "true" ]]; then
if [ "$FORCE_INSTALL" = "true" ]; then
error "Critical dependency '$pkg' is not available even with --force. Please install manually."
else
error "Critical dependency '$pkg' is not available. Try again with --force flag or install manually: apk add $pkg"
fi
all_ok=false
_all_ok=false
fi
done
if $all_ok; then
if $_all_ok; then
success "All required tools are available despite installation warnings"
return 0
else
@@ -391,10 +404,10 @@ if command -v apt-get >/dev/null 2>&1; then
# Check for broken packages
if dpkg -l | grep -q "^iH\|^iF" 2>/dev/null; then
if [[ "$FORCE_INSTALL" == "true" ]]; then
if [ "$FORCE_INSTALL" = "true" ]; then
warning "Detected broken packages on system - force mode will work around them"
else
warning "⚠️ Broken packages detected on system"
warning "Broken packages detected on system"
warning "If installation fails, retry with: curl -s {URL}/api/v1/hosts/install --force -H ..."
fi
fi
@@ -443,88 +456,88 @@ success "Dependencies installation completed"
echo ""
# Step 1: Handle existing configuration directory
info "📁 Setting up configuration directory..."
info "Setting up configuration directory..."
# Check if configuration directory already exists
if [[ -d "/etc/patchmon" ]]; then
warning "⚠️ Configuration directory already exists at /etc/patchmon"
warning "⚠️ Preserving existing configuration files"
if [ -d "/etc/patchmon" ]; then
warning "Configuration directory already exists at /etc/patchmon"
warning "Preserving existing configuration files"
# List existing files for user awareness
info "📋 Existing files in /etc/patchmon:"
info "Existing files in /etc/patchmon:"
ls -la /etc/patchmon/ 2>/dev/null | grep -v "^total" | while read -r line; do
echo " $line"
done
else
info "📁 Creating new configuration directory..."
info "Creating new configuration directory..."
mkdir -p /etc/patchmon
fi
# Check if agent is already configured and working (before we overwrite anything)
info "🔍 Checking if agent is already configured..."
info "Checking if agent is already configured..."
if [[ -f /etc/patchmon/config.yml ]] && [[ -f /etc/patchmon/credentials.yml ]]; then
if [[ -f /usr/local/bin/patchmon-agent ]]; then
info "📋 Found existing agent configuration"
info "🧪 Testing existing configuration with ping..."
if [ -f /etc/patchmon/config.yml ] && [ -f /etc/patchmon/credentials.yml ]; then
if [ -f /usr/local/bin/patchmon-agent ]; then
info "Found existing agent configuration"
info "Testing existing configuration with ping..."
if /usr/local/bin/patchmon-agent ping >/dev/null 2>&1; then
success "Agent is already configured and ping successful"
info "📋 Existing configuration is working - skipping installation"
success "Agent is already configured and ping successful"
info "Existing configuration is working - skipping installation"
info ""
info "If you want to reinstall, remove the configuration files first:"
info " sudo rm -f /etc/patchmon/config.yml /etc/patchmon/credentials.yml"
echo ""
exit 0
else
warning "⚠️ Agent configuration exists but ping failed"
warning "⚠️ Will move existing configuration and reinstall"
warning "Agent configuration exists but ping failed"
warning "Will move existing configuration and reinstall"
echo ""
fi
else
warning "⚠️ Configuration files exist but agent binary is missing"
warning "⚠️ Will move existing configuration and reinstall"
warning "Configuration files exist but agent binary is missing"
warning "Will move existing configuration and reinstall"
echo ""
fi
else
success "Agent not yet configured - proceeding with installation"
success "Agent not yet configured - proceeding with installation"
echo ""
fi
# Step 2: Create configuration files
info "🔐 Creating configuration files..."
info "Creating configuration files..."
# Check if config file already exists
if [[ -f "/etc/patchmon/config.yml" ]]; then
warning "⚠️ Config file already exists at /etc/patchmon/config.yml"
warning "⚠️ Moving existing file out of the way for fresh installation"
if [ -f "/etc/patchmon/config.yml" ]; then
warning "Config file already exists at /etc/patchmon/config.yml"
warning "Moving existing file out of the way for fresh installation"
# Clean up old config backups (keep only last 3)
ls -t /etc/patchmon/config.yml.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Move existing file out of the way
mv /etc/patchmon/config.yml /etc/patchmon/config.yml.backup.$(date +%Y%m%d_%H%M%S)
info "📋 Moved existing config to: /etc/patchmon/config.yml.backup.$(date +%Y%m%d_%H%M%S)"
info "Moved existing config to: /etc/patchmon/config.yml.backup.$(date +%Y%m%d_%H%M%S)"
fi
# Check if credentials file already exists
if [[ -f "/etc/patchmon/credentials.yml" ]]; then
warning "⚠️ Credentials file already exists at /etc/patchmon/credentials.yml"
warning "⚠️ Moving existing file out of the way for fresh installation"
if [ -f "/etc/patchmon/credentials.yml" ]; then
warning "Credentials file already exists at /etc/patchmon/credentials.yml"
warning "Moving existing file out of the way for fresh installation"
# Clean up old credential backups (keep only last 3)
ls -t /etc/patchmon/credentials.yml.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Move existing file out of the way
mv /etc/patchmon/credentials.yml /etc/patchmon/credentials.yml.backup.$(date +%Y%m%d_%H%M%S)
info "📋 Moved existing credentials to: /etc/patchmon/credentials.yml.backup.$(date +%Y%m%d_%H%M%S)"
info "Moved existing credentials to: /etc/patchmon/credentials.yml.backup.$(date +%Y%m%d_%H%M%S)"
fi
# Clean up old credentials file if it exists (from previous installations)
if [[ -f "/etc/patchmon/credentials" ]]; then
warning "⚠️ Found old credentials file, removing it..."
if [ -f "/etc/patchmon/credentials" ]; then
warning "Found old credentials file, removing it..."
rm -f /etc/patchmon/credentials
info "📋 Removed old credentials file"
info "Removed old credentials file"
fi
# Create main config file
@@ -551,29 +564,29 @@ chmod 600 /etc/patchmon/config.yml
chmod 600 /etc/patchmon/credentials.yml
# Step 3: Download the PatchMon agent binary using API credentials
info "📥 Downloading PatchMon agent binary..."
info "Downloading PatchMon agent binary..."
# Determine the binary filename based on architecture
BINARY_NAME="patchmon-agent-linux-${ARCHITECTURE}"
# Check if agent binary already exists
if [[ -f "/usr/local/bin/patchmon-agent" ]]; then
warning "⚠️ Agent binary already exists at /usr/local/bin/patchmon-agent"
warning "⚠️ Moving existing file out of the way for fresh installation"
if [ -f "/usr/local/bin/patchmon-agent" ]; then
warning "Agent binary already exists at /usr/local/bin/patchmon-agent"
warning "Moving existing file out of the way for fresh installation"
# Clean up old agent backups (keep only last 3)
ls -t /usr/local/bin/patchmon-agent.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Move existing file out of the way
mv /usr/local/bin/patchmon-agent /usr/local/bin/patchmon-agent.backup.$(date +%Y%m%d_%H%M%S)
info "📋 Moved existing agent to: /usr/local/bin/patchmon-agent.backup.$(date +%Y%m%d_%H%M%S)"
info "Moved existing agent to: /usr/local/bin/patchmon-agent.backup.$(date +%Y%m%d_%H%M%S)"
fi
# Clean up old shell script if it exists (from previous installations)
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
warning "⚠️ Found old shell script agent, removing it..."
if [ -f "/usr/local/bin/patchmon-agent.sh" ]; then
warning "Found old shell script agent, removing it..."
rm -f /usr/local/bin/patchmon-agent.sh
info "📋 Removed old shell script agent"
info "Removed old shell script agent"
fi
# Download the binary
@@ -587,49 +600,52 @@ chmod +x /usr/local/bin/patchmon-agent
# Get the agent version from the binary
AGENT_VERSION=$(/usr/local/bin/patchmon-agent version 2>/dev/null || echo "Unknown")
info "📋 Agent version: $AGENT_VERSION"
info "Agent version: $AGENT_VERSION"
# Handle existing log files and create log directory
info "📁 Setting up log directory..."
info "Setting up log directory..."
# Create log directory if it doesn't exist
mkdir -p /etc/patchmon/logs
# Handle existing log files
if [[ -f "/etc/patchmon/logs/patchmon-agent.log" ]]; then
warning "⚠️ Existing log file found at /etc/patchmon/logs/patchmon-agent.log"
warning "⚠️ Rotating log file for fresh start"
if [ -f "/etc/patchmon/logs/patchmon-agent.log" ]; then
warning "Existing log file found at /etc/patchmon/logs/patchmon-agent.log"
warning "Rotating log file for fresh start"
# Rotate the log file
mv /etc/patchmon/logs/patchmon-agent.log /etc/patchmon/logs/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)
info "📋 Log file rotated to: /etc/patchmon/logs/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)"
info "Log file rotated to: /etc/patchmon/logs/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)"
fi
# Step 4: Test the configuration
info "🧪 Testing API credentials and connectivity..."
info "Testing API credentials and connectivity..."
if /usr/local/bin/patchmon-agent ping; then
success "TEST: API credentials are valid and server is reachable"
success "TEST: API credentials are valid and server is reachable"
else
error "Failed to validate API credentials or reach server"
error "Failed to validate API credentials or reach server"
fi
# Step 5: Setup systemd service for WebSocket connection
# Step 5: Setup service for WebSocket connection
# Note: The service will automatically send an initial report on startup (see serve.go)
info "🔧 Setting up systemd service..."
# Stop and disable existing service if it exists
if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then
warning "⚠️ Stopping existing PatchMon agent service..."
systemctl stop patchmon-agent.service
fi
if systemctl is-enabled --quiet patchmon-agent.service 2>/dev/null; then
warning "⚠️ Disabling existing PatchMon agent service..."
systemctl disable patchmon-agent.service
fi
# Create systemd service file
cat > /etc/systemd/system/patchmon-agent.service << EOF
# Detect init system and create appropriate service
if command -v systemctl >/dev/null 2>&1; then
# Systemd is available
info "Setting up systemd service..."
# Stop and disable existing service if it exists
if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then
warning "Stopping existing PatchMon agent service..."
systemctl stop patchmon-agent.service
fi
if systemctl is-enabled --quiet patchmon-agent.service 2>/dev/null; then
warning "Disabling existing PatchMon agent service..."
systemctl disable patchmon-agent.service
fi
# Create systemd service file
cat > /etc/systemd/system/patchmon-agent.service << EOF
[Unit]
Description=PatchMon Agent Service
After=network.target
@@ -651,59 +667,154 @@ SyslogIdentifier=patchmon-agent
[Install]
WantedBy=multi-user.target
EOF
# Clean up old crontab entries if they exist (from previous installations)
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
warning "Found old crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
info "Removed old crontab entries"
fi
# Reload systemd and enable/start the service
systemctl daemon-reload
systemctl enable patchmon-agent.service
systemctl start patchmon-agent.service
# Check if service started successfully
if systemctl is-active --quiet patchmon-agent.service; then
success "PatchMon Agent service started successfully"
info "WebSocket connection established"
else
warning "Service may have failed to start. Check status with: systemctl status patchmon-agent"
fi
SERVICE_TYPE="systemd"
elif [ -d /etc/init.d ] && command -v rc-service >/dev/null 2>&1; then
# OpenRC is available (Alpine Linux)
info "Setting up OpenRC service..."
# Stop and disable existing service if it exists
if rc-service patchmon-agent status >/dev/null 2>&1; then
warning "Stopping existing PatchMon agent service..."
rc-service patchmon-agent stop
fi
if rc-update show default 2>/dev/null | grep -q "patchmon-agent"; then
warning "Disabling existing PatchMon agent service..."
rc-update del patchmon-agent default
fi
# Create OpenRC service file
cat > /etc/init.d/patchmon-agent << 'EOF'
#!/sbin/openrc-run
# Clean up old crontab entries if they exist (from previous installations)
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
warning "⚠️ Found old crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
info "📋 Removed old crontab entries"
fi
name="patchmon-agent"
description="PatchMon Agent Service"
command="/usr/local/bin/patchmon-agent"
command_args="serve"
command_user="root"
pidfile="/var/run/patchmon-agent.pid"
command_background="yes"
working_dir="/etc/patchmon"
# Reload systemd and enable/start the service
systemctl daemon-reload
systemctl enable patchmon-agent.service
systemctl start patchmon-agent.service
# Check if service started successfully
if systemctl is-active --quiet patchmon-agent.service; then
success "✅ PatchMon Agent service started successfully"
info "🔗 WebSocket connection established"
depend() {
need net
after net
}
EOF
chmod +x /etc/init.d/patchmon-agent
# Clean up old crontab entries if they exist (from previous installations)
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
warning "Found old crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
info "Removed old crontab entries"
fi
# Enable and start the service
rc-update add patchmon-agent default
rc-service patchmon-agent start
# Check if service started successfully
if rc-service patchmon-agent status >/dev/null 2>&1; then
success "PatchMon Agent service started successfully"
info "WebSocket connection established"
else
warning "Service may have failed to start. Check status with: rc-service patchmon-agent status"
fi
SERVICE_TYPE="openrc"
else
warning "⚠️ Service may have failed to start. Check status with: systemctl status patchmon-agent"
# No init system detected, use crontab as fallback
warning "No init system detected (systemd or OpenRC). Using crontab for service management."
# Clean up old crontab entries if they exist
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
warning "Found old crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
info "Removed old crontab entries"
fi
# Add crontab entry to run the agent
(crontab -l 2>/dev/null; echo "@reboot /usr/local/bin/patchmon-agent serve >/dev/null 2>&1") | crontab -
info "Added crontab entry for PatchMon agent"
# Start the agent manually
/usr/local/bin/patchmon-agent serve >/dev/null 2>&1 &
success "PatchMon Agent started in background"
info "WebSocket connection established"
SERVICE_TYPE="crontab"
fi
# Installation complete
success "🎉 PatchMon Agent installation completed successfully!"
success "PatchMon Agent installation completed successfully!"
echo ""
echo -e "${GREEN}📋 Installation Summary:${NC}"
printf "%b\n" "${GREEN}Installation Summary:${NC}"
echo " • Configuration directory: /etc/patchmon"
echo " • Agent binary installed: /usr/local/bin/patchmon-agent"
echo " • Architecture: $ARCHITECTURE"
echo " • Dependencies installed: jq, curl, bc"
echo " • Systemd service configured and running"
if [ "$SERVICE_TYPE" = "systemd" ]; then
echo " • Systemd service configured and running"
elif [ "$SERVICE_TYPE" = "openrc" ]; then
echo " • OpenRC service configured and running"
else
echo " • Service configured via crontab"
fi
echo " • API credentials configured and tested"
echo " • WebSocket connection established"
echo " • Logs directory: /etc/patchmon/logs"
# Check for moved files and show them
MOVED_FILES=$(ls /etc/patchmon/credentials.yml.backup.* /etc/patchmon/config.yml.backup.* /usr/local/bin/patchmon-agent.backup.* /etc/patchmon/logs/patchmon-agent.log.old.* /usr/local/bin/patchmon-agent.sh.backup.* /etc/patchmon/credentials.backup.* 2>/dev/null || true)
if [[ -n "$MOVED_FILES" ]]; then
if [ -n "$MOVED_FILES" ]; then
echo ""
echo -e "${YELLOW}📋 Files Moved for Fresh Installation:${NC}"
printf "%b\n" "${YELLOW}Files Moved for Fresh Installation:${NC}"
echo "$MOVED_FILES" | while read -r moved_file; do
echo "$moved_file"
done
echo ""
echo -e "${BLUE}💡 Note: Old files are automatically cleaned up (keeping last 3)${NC}"
printf "%b\n" "${BLUE}Note: Old files are automatically cleaned up (keeping last 3)${NC}"
fi
echo ""
echo -e "${BLUE}🔧 Management Commands:${NC}"
printf "%b\n" "${BLUE}Management Commands:${NC}"
echo " • Test connection: /usr/local/bin/patchmon-agent ping"
echo " • Manual report: /usr/local/bin/patchmon-agent report"
echo " • Check status: /usr/local/bin/patchmon-agent diagnostics"
echo " • Service status: systemctl status patchmon-agent"
echo " • Service logs: journalctl -u patchmon-agent -f"
echo " • Restart service: systemctl restart patchmon-agent"
if [ "$SERVICE_TYPE" = "systemd" ]; then
echo " • Service status: systemctl status patchmon-agent"
echo " • Service logs: journalctl -u patchmon-agent -f"
echo " • Restart service: systemctl restart patchmon-agent"
elif [ "$SERVICE_TYPE" = "openrc" ]; then
echo " • Service status: rc-service patchmon-agent status"
echo " • Service logs: tail -f /etc/patchmon/logs/patchmon-agent.log"
echo " • Restart service: rc-service patchmon-agent restart"
else
echo " • Service logs: tail -f /etc/patchmon/logs/patchmon-agent.log"
echo " • Restart service: pkill -f 'patchmon-agent serve' && /usr/local/bin/patchmon-agent serve &"
fi
echo ""
success "Your system is now being monitored by PatchMon!"
success "Your system is now being monitored by PatchMon!"

View File

@@ -1,7 +1,10 @@
#!/bin/bash
#!/bin/sh
# PatchMon Agent Removal Script
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/remove | bash
# POSIX-compliant shell script (works with dash, ash, bash, etc.)
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/remove | sudo sh
# curl -s {PATCHMON_URL}/api/v1/hosts/remove | sudo REMOVE_BACKUPS=1 sh
# curl -s {PATCHMON_URL}/api/v1/hosts/remove | sudo SILENT=1 sh
# This script completely removes PatchMon from the system
set -e
@@ -11,80 +14,271 @@ set -e
# future (left for consistency with install script).
CURL_FLAGS=""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Detect if running in silent mode (only with explicit SILENT env var)
SILENT_MODE=0
if [ -n "$SILENT" ]; then
SILENT_MODE=1
fi
# Check if backup files should be removed (default: preserve for safety)
# Usage: REMOVE_BACKUPS=1 when piping the script
REMOVE_BACKUPS="${REMOVE_BACKUPS:-0}"
# Colors for output (disabled in silent mode)
if [ "$SILENT_MODE" -eq 0 ]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
else
RED=''
GREEN=''
YELLOW=''
BLUE=''
NC=''
fi
# Functions
error() {
echo -e "${RED}❌ ERROR: $1${NC}" >&2
printf "%b\n" "${RED}❌ ERROR: $1${NC}" >&2
exit 1
}
info() {
echo -e "${BLUE} $1${NC}"
if [ "$SILENT_MODE" -eq 0 ]; then
printf "%b\n" "${BLUE} $1${NC}"
fi
}
success() {
echo -e "${GREEN}$1${NC}"
if [ "$SILENT_MODE" -eq 0 ]; then
printf "%b\n" "${GREEN}$1${NC}"
fi
}
warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
if [ "$SILENT_MODE" -eq 0 ]; then
printf "%b\n" "${YELLOW}⚠️ $1${NC}"
fi
}
# Check if running as root
if [[ $EUID -ne 0 ]]; then
if [ "$(id -u)" -ne 0 ]; then
error "This script must be run as root (use sudo)"
fi
info "🗑️ Starting PatchMon Agent Removal..."
echo ""
[ "$SILENT_MODE" -eq 0 ] && echo ""
# Step 1: Stop any running PatchMon processes
info "🛑 Stopping PatchMon processes..."
if pgrep -f "patchmon-agent.sh" >/dev/null; then
warning "Found running PatchMon processes, stopping them..."
pkill -f "patchmon-agent.sh" || true
# Step 1: Stop systemd/OpenRC service if it exists
info "🛑 Stopping PatchMon service..."
SERVICE_STOPPED=0
# Check for systemd service
if command -v systemctl >/dev/null 2>&1; then
info "📋 Checking systemd service status..."
# Check if service is active
if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then
SERVICE_STATUS=$(systemctl is-active patchmon-agent.service 2>/dev/null || echo "unknown")
warning "Service is active (status: $SERVICE_STATUS). Stopping it now..."
if systemctl stop patchmon-agent.service 2>/dev/null; then
success "✓ Service stopped successfully"
SERVICE_STOPPED=1
else
warning "✗ Failed to stop service (continuing anyway...)"
fi
# Verify it stopped
sleep 1
if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then
warning "⚠️ Service is STILL ACTIVE after stop command!"
else
info "✓ Verified: Service is no longer active"
fi
else
info "Service is not active"
fi
# Check if service is enabled
if systemctl is-enabled --quiet patchmon-agent.service 2>/dev/null; then
ENABLED_STATUS=$(systemctl is-enabled patchmon-agent.service 2>/dev/null || echo "unknown")
warning "Service is enabled (status: $ENABLED_STATUS). Disabling it now..."
if systemctl disable patchmon-agent.service 2>/dev/null; then
success "✓ Service disabled successfully"
else
warning "✗ Failed to disable service (may already be disabled)"
fi
else
info "Service is not enabled"
fi
# Check for service file
if [ -f "/etc/systemd/system/patchmon-agent.service" ]; then
warning "Found service file: /etc/systemd/system/patchmon-agent.service"
info "Removing service file..."
if rm -f /etc/systemd/system/patchmon-agent.service 2>/dev/null; then
success "✓ Service file removed"
else
warning "✗ Failed to remove service file (check permissions)"
fi
info "Reloading systemd daemon..."
if systemctl daemon-reload 2>/dev/null; then
success "✓ Systemd daemon reloaded"
else
warning "✗ Failed to reload systemd daemon"
fi
SERVICE_STOPPED=1
# Verify the file is gone
if [ -f "/etc/systemd/system/patchmon-agent.service" ]; then
warning "⚠️ Service file STILL EXISTS after removal!"
else
info "✓ Verified: Service file removed"
fi
else
info "Service file not found at /etc/systemd/system/patchmon-agent.service"
fi
# Final status check
info "📊 Final systemd status check..."
FINAL_STATUS=$(systemctl is-active patchmon-agent.service 2>&1 || echo "not-found")
info "Service status: $FINAL_STATUS"
fi
# Check for OpenRC service (Alpine Linux)
if command -v rc-service >/dev/null 2>&1; then
if rc-service patchmon-agent status >/dev/null 2>&1; then
warning "Stopping OpenRC service..."
rc-service patchmon-agent stop || true
SERVICE_STOPPED=1
fi
if rc-update show default 2>/dev/null | grep -q "patchmon-agent"; then
warning "Removing from runlevel..."
rc-update del patchmon-agent default || true
fi
if [ -f "/etc/init.d/patchmon-agent" ]; then
warning "Removing OpenRC service file..."
rm -f /etc/init.d/patchmon-agent
success "OpenRC service removed"
SERVICE_STOPPED=1
fi
fi
# Stop any remaining running processes (legacy or manual starts)
info "🔍 Checking for running PatchMon processes..."
if pgrep -f "patchmon-agent" >/dev/null; then
PROCESS_COUNT=$(pgrep -f "patchmon-agent" | wc -l | tr -d ' ')
warning "Found $PROCESS_COUNT running PatchMon process(es)"
# Show process details
if [ "$SILENT_MODE" -eq 0 ]; then
info "Process details:"
ps aux | grep "[p]atchmon-agent" | while IFS= read -r line; do
echo " $line"
done
fi
warning "Sending SIGTERM to all patchmon-agent processes..."
if pkill -f "patchmon-agent" 2>/dev/null; then
success "✓ Sent SIGTERM signal"
else
warning "Failed to send SIGTERM (processes may have already stopped)"
fi
sleep 2
success "PatchMon processes stopped"
# Check if processes still exist
if pgrep -f "patchmon-agent" >/dev/null; then
REMAINING=$(pgrep -f "patchmon-agent" | wc -l | tr -d ' ')
warning "⚠️ $REMAINING process(es) still running! Sending SIGKILL..."
pkill -9 -f "patchmon-agent" 2>/dev/null || true
sleep 1
if pgrep -f "patchmon-agent" >/dev/null; then
warning "⚠️ CRITICAL: Processes still running after SIGKILL!"
else
success "✓ All processes terminated"
fi
else
success "✓ All processes stopped successfully"
fi
SERVICE_STOPPED=1
else
info "No running PatchMon processes found"
fi
if [ "$SERVICE_STOPPED" -eq 1 ]; then
success "PatchMon service/processes stopped"
else
info "No running PatchMon service or processes found"
fi
# Step 2: Remove crontab entries
info "📅 Removing PatchMon crontab entries..."
if crontab -l 2>/dev/null | grep -q "patchmon-agent.sh"; then
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
warning "Found PatchMon crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent.sh" | crontab -
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
success "Crontab entries removed"
else
info "No PatchMon crontab entries found"
fi
# Step 3: Remove agent script
info "📄 Removing agent script..."
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
warning "Removing agent script: /usr/local/bin/patchmon-agent.sh"
# Step 3: Remove agent binaries and scripts
info "📄 Removing agent binaries and scripts..."
AGENTS_REMOVED=0
# Remove Go agent binary
if [ -f "/usr/local/bin/patchmon-agent" ]; then
warning "Removing Go agent binary: /usr/local/bin/patchmon-agent"
rm -f /usr/local/bin/patchmon-agent
AGENTS_REMOVED=1
fi
# Remove legacy shell script agent
if [ -f "/usr/local/bin/patchmon-agent.sh" ]; then
warning "Removing legacy agent script: /usr/local/bin/patchmon-agent.sh"
rm -f /usr/local/bin/patchmon-agent.sh
success "Agent script removed"
AGENTS_REMOVED=1
fi
# Remove backup files for Go agent
if ls /usr/local/bin/patchmon-agent.backup.* >/dev/null 2>&1; then
warning "Removing Go agent backup files..."
rm -f /usr/local/bin/patchmon-agent.backup.*
AGENTS_REMOVED=1
fi
# Remove backup files for legacy shell script
if ls /usr/local/bin/patchmon-agent.sh.backup.* >/dev/null 2>&1; then
warning "Removing legacy agent backup files..."
rm -f /usr/local/bin/patchmon-agent.sh.backup.*
AGENTS_REMOVED=1
fi
if [ "$AGENTS_REMOVED" -eq 1 ]; then
success "Agent binaries and scripts removed"
else
info "Agent script not found"
info "No agent binaries or scripts found"
fi
# Step 4: Remove configuration directory and files
info "📁 Removing configuration files..."
if [[ -d "/etc/patchmon" ]]; then
if [ -d "/etc/patchmon" ]; then
warning "Removing configuration directory: /etc/patchmon"
# Show what's being removed
info "📋 Files in /etc/patchmon:"
ls -la /etc/patchmon/ 2>/dev/null | grep -v "^total" | while read -r line; do
echo " $line"
done
# Show what's being removed (only in verbose mode)
if [ "$SILENT_MODE" -eq 0 ]; then
info "📋 Files in /etc/patchmon:"
ls -la /etc/patchmon/ 2>/dev/null | grep -v "^total" | while read -r line; do
echo " $line"
done
fi
# Remove the directory
rm -rf /etc/patchmon
@@ -95,7 +289,7 @@ fi
# Step 5: Remove log files
info "📝 Removing log files..."
if [[ -f "/var/log/patchmon-agent.log" ]]; then
if [ -f "/var/log/patchmon-agent.log" ]; then
warning "Removing log file: /var/log/patchmon-agent.log"
rm -f /var/log/patchmon-agent.log
success "Log file removed"
@@ -103,120 +297,164 @@ else
info "Log file not found"
fi
# Step 6: Clean up backup files (optional)
info "🧹 Cleaning up backup files..."
# Step 6: Clean up backup files
info "🧹 Checking backup files..."
BACKUP_COUNT=0
BACKUP_REMOVED=0
# Count credential backups
CRED_BACKUPS=$(ls /etc/patchmon/credentials.backup.* 2>/dev/null | wc -l || echo "0")
if [[ $CRED_BACKUPS -gt 0 ]]; then
BACKUP_COUNT=$((BACKUP_COUNT + CRED_BACKUPS))
fi
# Count agent backups
AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | wc -l || echo "0")
if [[ $AGENT_BACKUPS -gt 0 ]]; then
BACKUP_COUNT=$((BACKUP_COUNT + AGENT_BACKUPS))
fi
# Count log backups
LOG_BACKUPS=$(ls /var/log/patchmon-agent.log.old.* 2>/dev/null | wc -l || echo "0")
if [[ $LOG_BACKUPS -gt 0 ]]; then
BACKUP_COUNT=$((BACKUP_COUNT + LOG_BACKUPS))
fi
if [[ $BACKUP_COUNT -gt 0 ]]; then
warning "Found $BACKUP_COUNT backup files"
echo ""
echo -e "${YELLOW}📋 Backup files found:${NC}"
if [ "$REMOVE_BACKUPS" -eq 1 ]; then
info "Removing backup files (REMOVE_BACKUPS=1)..."
# Show credential backups
if [[ $CRED_BACKUPS -gt 0 ]]; then
echo " Credential backups:"
ls /etc/patchmon/credentials.backup.* 2>/dev/null | while read -r file; do
echo "$file"
done
# Remove credential backups (already removed with /etc/patchmon directory, but check anyway)
if ls /etc/patchmon/credentials.backup.* >/dev/null 2>&1; then
CRED_BACKUPS=$(ls /etc/patchmon/credentials.backup.* 2>/dev/null | wc -l | tr -d ' ')
warning "Removing $CRED_BACKUPS credential backup file(s)..."
rm -f /etc/patchmon/credentials.backup.*
BACKUP_COUNT=$((BACKUP_COUNT + CRED_BACKUPS))
BACKUP_REMOVED=1
fi
# Show agent backups
if [[ $AGENT_BACKUPS -gt 0 ]]; then
echo " Agent script backups:"
ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | while read -r file; do
echo "$file"
done
# Remove config backups (already removed with /etc/patchmon directory, but check anyway)
if ls /etc/patchmon/*.backup.* >/dev/null 2>&1; then
CONFIG_BACKUPS=$(ls /etc/patchmon/*.backup.* 2>/dev/null | wc -l | tr -d ' ')
warning "Removing $CONFIG_BACKUPS config backup file(s)..."
rm -f /etc/patchmon/*.backup.*
BACKUP_COUNT=$((BACKUP_COUNT + CONFIG_BACKUPS))
BACKUP_REMOVED=1
fi
# Show log backups
if [[ $LOG_BACKUPS -gt 0 ]]; then
echo " Log file backups:"
ls /var/log/patchmon-agent.log.old.* 2>/dev/null | while read -r file; do
echo "$file"
done
# Remove Go agent backups
if ls /usr/local/bin/patchmon-agent.backup.* >/dev/null 2>&1; then
GO_AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.backup.* 2>/dev/null | wc -l | tr -d ' ')
warning "Removing $GO_AGENT_BACKUPS Go agent backup file(s)..."
rm -f /usr/local/bin/patchmon-agent.backup.*
BACKUP_COUNT=$((BACKUP_COUNT + GO_AGENT_BACKUPS))
BACKUP_REMOVED=1
fi
echo ""
echo -e "${BLUE}💡 Note: Backup files are preserved for safety${NC}"
echo -e "${BLUE}💡 You can remove them manually if not needed${NC}"
# Remove legacy shell agent backups
if ls /usr/local/bin/patchmon-agent.sh.backup.* >/dev/null 2>&1; then
SHELL_AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | wc -l | tr -d ' ')
warning "Removing $SHELL_AGENT_BACKUPS legacy agent backup file(s)..."
rm -f /usr/local/bin/patchmon-agent.sh.backup.*
BACKUP_COUNT=$((BACKUP_COUNT + SHELL_AGENT_BACKUPS))
BACKUP_REMOVED=1
fi
# Remove log backups
if ls /var/log/patchmon-agent.log.old.* >/dev/null 2>&1; then
LOG_BACKUPS=$(ls /var/log/patchmon-agent.log.old.* 2>/dev/null | wc -l | tr -d ' ')
warning "Removing $LOG_BACKUPS log backup file(s)..."
rm -f /var/log/patchmon-agent.log.old.*
BACKUP_COUNT=$((BACKUP_COUNT + LOG_BACKUPS))
BACKUP_REMOVED=1
fi
if [ "$BACKUP_REMOVED" -eq 1 ]; then
success "Removed $BACKUP_COUNT backup file(s)"
else
info "No backup files found to remove"
fi
else
info "No backup files found"
# Just count backup files without removing
CRED_BACKUPS=0
CONFIG_BACKUPS=0
GO_AGENT_BACKUPS=0
SHELL_AGENT_BACKUPS=0
LOG_BACKUPS=0
if ls /etc/patchmon/credentials.backup.* >/dev/null 2>&1; then
CRED_BACKUPS=$(ls /etc/patchmon/credentials.backup.* 2>/dev/null | wc -l | tr -d ' ')
fi
if ls /etc/patchmon/*.backup.* >/dev/null 2>&1; then
CONFIG_BACKUPS=$(ls /etc/patchmon/*.backup.* 2>/dev/null | wc -l | tr -d ' ')
fi
if ls /usr/local/bin/patchmon-agent.backup.* >/dev/null 2>&1; then
GO_AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.backup.* 2>/dev/null | wc -l | tr -d ' ')
fi
if ls /usr/local/bin/patchmon-agent.sh.backup.* >/dev/null 2>&1; then
SHELL_AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | wc -l | tr -d ' ')
fi
if ls /var/log/patchmon-agent.log.old.* >/dev/null 2>&1; then
LOG_BACKUPS=$(ls /var/log/patchmon-agent.log.old.* 2>/dev/null | wc -l | tr -d ' ')
fi
BACKUP_COUNT=$((CRED_BACKUPS + CONFIG_BACKUPS + GO_AGENT_BACKUPS + SHELL_AGENT_BACKUPS + LOG_BACKUPS))
if [ "$BACKUP_COUNT" -gt 0 ]; then
info "Found $BACKUP_COUNT backup file(s) - preserved for safety"
if [ "$SILENT_MODE" -eq 0 ]; then
printf "%b\n" "${BLUE}💡 To remove backups, run with: REMOVE_BACKUPS=1${NC}"
fi
else
info "No backup files found"
fi
fi
# Step 7: Remove dependencies (optional)
info "📦 Checking for PatchMon-specific dependencies..."
if command -v jq >/dev/null 2>&1; then
warning "jq is installed (used by PatchMon)"
echo -e "${BLUE}💡 Note: jq may be used by other applications${NC}"
echo -e "${BLUE}💡 Consider keeping it unless you're sure it's not needed${NC}"
else
info "jq not found"
fi
if command -v curl >/dev/null 2>&1; then
warning "curl is installed (used by PatchMon)"
echo -e "${BLUE}💡 Note: curl is commonly used by many applications${NC}"
echo -e "${BLUE}💡 Consider keeping it unless you're sure it's not needed${NC}"
else
info "curl not found"
fi
# Step 8: Final verification
# Step 7: Final verification
info "🔍 Verifying removal..."
REMAINING_FILES=0
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
if [ -f "/usr/local/bin/patchmon-agent" ]; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if [[ -d "/etc/patchmon" ]]; then
if [ -f "/usr/local/bin/patchmon-agent.sh" ]; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if [[ -f "/var/log/patchmon-agent.log" ]]; then
if [ -f "/etc/systemd/system/patchmon-agent.service" ]; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if crontab -l 2>/dev/null | grep -q "patchmon-agent.sh"; then
if [ -f "/etc/init.d/patchmon-agent" ]; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if [[ $REMAINING_FILES -eq 0 ]]; then
if [ -d "/etc/patchmon" ]; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if [ -f "/var/log/patchmon-agent.log" ]; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if [ "$REMAINING_FILES" -eq 0 ]; then
success "✅ PatchMon has been completely removed from the system!"
else
warning "⚠️ Some PatchMon files may still remain ($REMAINING_FILES items)"
echo -e "${BLUE}💡 You may need to remove them manually${NC}"
if [ "$SILENT_MODE" -eq 0 ]; then
printf "%b\n" "${BLUE}💡 You may need to remove them manually${NC}"
fi
fi
echo ""
echo -e "${GREEN}📋 Removal Summary:${NC}"
echo " • Agent script: Removed"
echo " • Configuration files: Removed"
echo " • Log files: Removed"
echo " • Crontab entries: Removed"
echo " • Running processes: Stopped"
echo " • Backup files: Preserved (if any)"
echo ""
echo -e "${BLUE}🔧 Manual cleanup (if needed):${NC}"
echo " • Remove backup files: rm /etc/patchmon/credentials.backup.* /usr/local/bin/patchmon-agent.sh.backup.* /var/log/patchmon-agent.log.old.*"
echo " • Remove dependencies: apt remove jq curl (if not needed by other apps)"
echo ""
if [ "$SILENT_MODE" -eq 0 ]; then
echo ""
printf "%b\n" "${GREEN}📋 Removal Summary:${NC}"
echo " • Agent binaries: Removed"
echo " • System services: Removed (systemd/OpenRC)"
echo " • Configuration files: Removed"
echo " • Log files: Removed"
echo " • Crontab entries: Removed"
echo " • Running processes: Stopped"
if [ "$REMOVE_BACKUPS" -eq 1 ]; then
echo " • Backup files: Removed"
else
echo " • Backup files: Preserved (${BACKUP_COUNT} files)"
fi
echo ""
if [ "$REMOVE_BACKUPS" -eq 0 ] && [ "$BACKUP_COUNT" -gt 0 ]; then
printf "%b\n" "${BLUE}🔧 Manual cleanup (if needed):${NC}"
echo " • Remove backup files: curl -s \${PATCHMON_URL}/api/v1/hosts/remove | sudo REMOVE_BACKUPS=1 sh"
echo ""
fi
fi
success "🎉 PatchMon removal completed!"

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon-backend",
"version": "1.3.3",
"version": "1.3.4",
"description": "Backend API for Linux Patch Monitoring System",
"license": "AGPL-3.0",
"main": "src/server.js",

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "auto_enrollment_tokens" ADD COLUMN "scopes" JSONB;

View File

@@ -0,0 +1,13 @@
-- Remove machine_id unique constraint and make it nullable
-- This allows multiple hosts with the same machine_id
-- Duplicate detection now relies on config.yml/credentials.yml checking instead
-- Drop the unique constraint
ALTER TABLE "hosts" DROP CONSTRAINT IF EXISTS "hosts_machine_id_key";
-- Make machine_id nullable
ALTER TABLE "hosts" ALTER COLUMN "machine_id" DROP NOT NULL;
-- Keep the index for query performance (but not unique)
CREATE INDEX IF NOT EXISTS "hosts_machine_id_idx" ON "hosts"("machine_id");

View File

@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "hosts" ADD COLUMN "needs_reboot" BOOLEAN DEFAULT false;
ALTER TABLE "hosts" ADD COLUMN "installed_kernel_version" TEXT;
-- CreateIndex
CREATE INDEX "hosts_needs_reboot_idx" ON "hosts"("needs_reboot");

View File

@@ -1,5 +1,6 @@
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
@@ -81,7 +82,7 @@ model host_repositories {
model hosts {
id String @id
machine_id String @unique
machine_id String?
friendly_name String
ip String?
os_type String
@@ -109,6 +110,7 @@ model hosts {
swap_size Int?
system_uptime String?
notes String?
needs_reboot Boolean? @default(false)
host_packages host_packages[]
host_repositories host_repositories[]
host_group_memberships host_group_memberships[]
@@ -120,6 +122,7 @@ model hosts {
@@index([machine_id])
@@index([friendly_name])
@@index([hostname])
@@index([needs_reboot])
}
model packages {
@@ -288,6 +291,7 @@ model auto_enrollment_tokens {
last_used_at DateTime?
expires_at DateTime?
metadata Json?
scopes Json?
users users? @relation(fields: [created_by_user_id], references: [id], onDelete: SetNull)
host_groups host_groups? @relation(fields: [default_host_group_id], references: [id], onDelete: SetNull)

View File

@@ -0,0 +1,113 @@
const { getPrismaClient } = require("../config/prisma");
const bcrypt = require("bcryptjs");
const prisma = getPrismaClient();
/**
* Middleware factory to authenticate API tokens using Basic Auth
* @param {string} integrationType - The expected integration type (e.g., "api", "gethomepage")
* @returns {Function} Express middleware function
*/
const authenticateApiToken = (integrationType) => {
return 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 the expected integration type
if (token.metadata?.integration_type !== integrationType) {
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" });
}
};
};
module.exports = { authenticateApiToken };

View File

@@ -0,0 +1,76 @@
/**
* Middleware factory to validate API token scopes
* Only applies to tokens with metadata.integration_type === "api"
* @param {string} resource - The resource being accessed (e.g., "host")
* @param {string} action - The action being performed (e.g., "get", "put", "patch", "update", "delete")
* @returns {Function} Express middleware function
*/
const requireApiScope = (resource, action) => {
return async (req, res, next) => {
try {
const token = req.apiToken;
// If no token attached, this should have been caught by auth middleware
if (!token) {
return res.status(401).json({ error: "Unauthorized" });
}
// Only validate scopes for API type tokens
if (token.metadata?.integration_type !== "api") {
// For non-API tokens, skip scope validation
return next();
}
// Check if token has scopes field
if (!token.scopes || typeof token.scopes !== "object") {
console.warn(
`API token ${token.token_key} missing scopes field for ${resource}:${action}`,
);
return res.status(403).json({
error: "Access denied",
message: "This API key does not have the required permissions",
});
}
// Check if resource exists in scopes
if (!token.scopes[resource]) {
console.warn(
`API token ${token.token_key} missing resource ${resource} for ${action}`,
);
return res.status(403).json({
error: "Access denied",
message: `This API key does not have access to ${resource}`,
});
}
// Check if action exists in resource scopes
if (!Array.isArray(token.scopes[resource])) {
console.warn(
`API token ${token.token_key} has invalid scopes structure for ${resource}`,
);
return res.status(403).json({
error: "Access denied",
message: "Invalid API key permissions configuration",
});
}
if (!token.scopes[resource].includes(action)) {
console.warn(
`API token ${token.token_key} missing action ${action} for resource ${resource}`,
);
return res.status(403).json({
error: "Access denied",
message: `This API key does not have permission to ${action} ${resource}`,
});
}
// Scope validation passed
next();
} catch (error) {
console.error("Scope validation error:", error);
res.status(500).json({ error: "Scope validation failed" });
}
};
};
module.exports = { requireApiScope };

View File

@@ -0,0 +1,143 @@
const express = require("express");
const { getPrismaClient } = require("../config/prisma");
const { authenticateApiToken } = require("../middleware/apiAuth");
const { requireApiScope } = require("../middleware/apiScope");
const router = express.Router();
const prisma = getPrismaClient();
// Helper function to check if a string is a valid UUID
const isUUID = (str) => {
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
};
// GET /api/v1/api/hosts - List hosts with IP and groups
router.get(
"/hosts",
authenticateApiToken("api"),
requireApiScope("host", "get"),
async (req, res) => {
try {
const { hostgroup } = req.query;
let whereClause = {};
let filterValues = [];
// Parse hostgroup filter (comma-separated names or UUIDs)
if (hostgroup) {
filterValues = hostgroup.split(",").map((g) => g.trim());
// Separate UUIDs from names
const uuidFilters = [];
const nameFilters = [];
for (const value of filterValues) {
if (isUUID(value)) {
uuidFilters.push(value);
} else {
nameFilters.push(value);
}
}
// Find host group IDs from names
const groupIds = [...uuidFilters];
if (nameFilters.length > 0) {
const groups = await prisma.host_groups.findMany({
where: {
name: {
in: nameFilters,
},
},
select: {
id: true,
name: true,
},
});
// Add found group IDs
groupIds.push(...groups.map((g) => g.id));
// Check if any name filters didn't match
const foundNames = groups.map((g) => g.name);
const notFoundNames = nameFilters.filter(
(name) => !foundNames.includes(name),
);
if (notFoundNames.length > 0) {
console.warn(`Host groups not found: ${notFoundNames.join(", ")}`);
}
}
// Filter hosts by group memberships
if (groupIds.length > 0) {
whereClause = {
host_group_memberships: {
some: {
host_group_id: {
in: groupIds,
},
},
},
};
} else {
// No valid groups found, return empty result
return res.json({
hosts: [],
total: 0,
filtered_by_groups: filterValues,
});
}
}
// Query hosts with groups
const hosts = await prisma.hosts.findMany({
where: whereClause,
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
host_group_memberships: {
include: {
host_groups: {
select: {
id: true,
name: true,
},
},
},
},
},
orderBy: {
friendly_name: "asc",
},
});
// Format response
const formattedHosts = hosts.map((host) => ({
id: host.id,
friendly_name: host.friendly_name,
hostname: host.hostname,
ip: host.ip,
host_groups: host.host_group_memberships.map((membership) => ({
id: membership.host_groups.id,
name: membership.host_groups.name,
})),
}));
res.json({
hosts: formattedHosts,
total: formattedHosts.length,
filtered_by_groups: filterValues.length > 0 ? filterValues : undefined,
});
} catch (error) {
console.error("Error fetching hosts:", error);
res.status(500).json({ error: "Failed to fetch hosts" });
}
},
);
module.exports = router;

View File

@@ -125,6 +125,10 @@ router.post(
.optional({ nullable: true, checkFalsy: true })
.isISO8601()
.withMessage("Invalid date format"),
body("scopes")
.optional()
.isObject()
.withMessage("Scopes must be an object"),
],
async (req, res) => {
try {
@@ -140,6 +144,7 @@ router.post(
default_host_group_id,
expires_at,
metadata = {},
scopes,
} = req.body;
// Validate host group if provided
@@ -153,6 +158,32 @@ router.post(
}
}
// Validate scopes for API tokens
if (metadata.integration_type === "api" && scopes) {
// Validate scopes structure
if (typeof scopes !== "object" || scopes === null) {
return res.status(400).json({ error: "Scopes must be an object" });
}
// Validate each resource in scopes
for (const [resource, actions] of Object.entries(scopes)) {
if (!Array.isArray(actions)) {
return res.status(400).json({
error: `Scopes for resource "${resource}" must be an array of actions`,
});
}
// Validate action names
for (const action of actions) {
if (typeof action !== "string") {
return res.status(400).json({
error: `All actions in scopes must be strings`,
});
}
}
}
}
const { token_key, token_secret } = generate_auto_enrollment_token();
const hashed_secret = await bcrypt.hash(token_secret, 10);
@@ -168,6 +199,7 @@ router.post(
default_host_group_id: default_host_group_id || null,
expires_at: expires_at ? new Date(expires_at) : null,
metadata: { integration_type: "proxmox-lxc", ...metadata },
scopes: metadata.integration_type === "api" ? scopes || null : null,
updated_at: new Date(),
},
include: {
@@ -201,6 +233,7 @@ router.post(
default_host_group: token.host_groups,
created_by: token.users,
expires_at: token.expires_at,
scopes: token.scopes,
},
warning: "⚠️ Save the token_secret now - it cannot be retrieved later!",
});
@@ -232,6 +265,7 @@ router.get(
created_at: true,
default_host_group_id: true,
metadata: true,
scopes: true,
host_groups: {
select: {
id: true,
@@ -314,6 +348,10 @@ router.patch(
body("max_hosts_per_day").optional().isInt({ min: 1, max: 1000 }),
body("allowed_ip_ranges").optional().isArray(),
body("expires_at").optional().isISO8601(),
body("scopes")
.optional()
.isObject()
.withMessage("Scopes must be an object"),
],
async (req, res) => {
try {
@@ -323,6 +361,16 @@ router.patch(
}
const { tokenId } = req.params;
// First, get the existing token to check its integration type
const existing_token = await prisma.auto_enrollment_tokens.findUnique({
where: { id: tokenId },
});
if (!existing_token) {
return res.status(404).json({ error: "Token not found" });
}
const update_data = { updated_at: new Date() };
if (req.body.is_active !== undefined)
@@ -334,6 +382,41 @@ router.patch(
if (req.body.expires_at !== undefined)
update_data.expires_at = new Date(req.body.expires_at);
// Handle scopes updates for API tokens only
if (req.body.scopes !== undefined) {
if (existing_token.metadata?.integration_type === "api") {
// Validate scopes structure
const scopes = req.body.scopes;
if (typeof scopes !== "object" || scopes === null) {
return res.status(400).json({ error: "Scopes must be an object" });
}
// Validate each resource in scopes
for (const [resource, actions] of Object.entries(scopes)) {
if (!Array.isArray(actions)) {
return res.status(400).json({
error: `Scopes for resource "${resource}" must be an array of actions`,
});
}
// Validate action names
for (const action of actions) {
if (typeof action !== "string") {
return res.status(400).json({
error: `All actions in scopes must be strings`,
});
}
}
}
update_data.scopes = scopes;
} else {
return res.status(400).json({
error: "Scopes can only be updated for API integration tokens",
});
}
}
const token = await prisma.auto_enrollment_tokens.update({
where: { id: tokenId },
data: update_data,
@@ -398,19 +481,22 @@ router.delete(
);
// ========== AUTO-ENROLLMENT ENDPOINTS (Used by Scripts) ==========
// Future integrations can follow this pattern:
// - /proxmox-lxc - Proxmox LXC containers
// - /vmware-esxi - VMware ESXi VMs
// - /docker - Docker containers
// - /kubernetes - Kubernetes pods
// - /aws-ec2 - AWS EC2 instances
// Universal script-serving endpoint with type parameter
// Supported types:
// - proxmox-lxc - Proxmox LXC containers
// - direct-host - Direct host enrollment
// Future types:
// - vmware-esxi - VMware ESXi VMs
// - docker - Docker containers
// - kubernetes - Kubernetes pods
// Serve the Proxmox LXC enrollment script with credentials injected
router.get("/proxmox-lxc", async (req, res) => {
// Serve auto-enrollment scripts with credentials injected
router.get("/script", async (req, res) => {
try {
// Get token from query params
// Get parameters from query params
const token_key = req.query.token_key;
const token_secret = req.query.token_secret;
const script_type = req.query.type;
if (!token_key || !token_secret) {
return res
@@ -418,6 +504,25 @@ router.get("/proxmox-lxc", async (req, res) => {
.json({ error: "Token key and secret required as query parameters" });
}
if (!script_type) {
return res.status(400).json({
error:
"Script type required as query parameter (e.g., ?type=proxmox-lxc or ?type=direct-host)",
});
}
// Map script types to script file paths
const scriptMap = {
"proxmox-lxc": "proxmox_auto_enroll.sh",
"direct-host": "direct_host_auto_enroll.sh",
};
if (!scriptMap[script_type]) {
return res.status(400).json({
error: `Invalid script type: ${script_type}. Supported types: ${Object.keys(scriptMap).join(", ")}`,
});
}
// Validate token
const token = await prisma.auto_enrollment_tokens.findUnique({
where: { token_key: token_key },
@@ -443,13 +548,13 @@ router.get("/proxmox-lxc", async (req, res) => {
const script_path = path.join(
__dirname,
"../../../agents/proxmox_auto_enroll.sh",
`../../../agents/${scriptMap[script_type]}`,
);
if (!fs.existsSync(script_path)) {
return res
.status(404)
.json({ error: "Proxmox enrollment script not found" });
return res.status(404).json({
error: `Enrollment script not found: ${scriptMap[script_type]}`,
});
}
let script = fs.readFileSync(script_path, "utf8");
@@ -484,7 +589,7 @@ router.get("/proxmox-lxc", async (req, res) => {
const force_install = req.query.force === "true" || req.query.force === "1";
// Inject the token credentials, server URL, curl flags, and force flag into the script
const env_vars = `#!/bin/bash
const env_vars = `#!/bin/sh
# PatchMon Auto-Enrollment Configuration (Auto-generated)
export PATCHMON_URL="${server_url}"
export AUTO_ENROLLMENT_KEY="${token.token_key}"
@@ -508,11 +613,11 @@ export FORCE_INSTALL="${force_install ? "true" : "false"}"
res.setHeader("Content-Type", "text/plain");
res.setHeader(
"Content-Disposition",
'inline; filename="proxmox_auto_enroll.sh"',
`inline; filename="${scriptMap[script_type]}"`,
);
res.send(script);
} catch (error) {
console.error("Proxmox script serve error:", error);
console.error("Script serve error:", error);
res.status(500).json({ error: "Failed to serve enrollment script" });
}
});
@@ -526,8 +631,11 @@ router.post(
.isLength({ min: 1, max: 255 })
.withMessage("Friendly name is required"),
body("machine_id")
.optional()
.isLength({ min: 1, max: 255 })
.withMessage("Machine ID is required"),
.withMessage(
"Machine ID must be between 1 and 255 characters if provided",
),
body("metadata").optional().isObject(),
],
async (req, res) => {
@@ -543,24 +651,7 @@ router.post(
const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`;
const api_key = crypto.randomBytes(32).toString("hex");
// Check if host already exists by machine_id (not hostname)
const existing_host = await prisma.hosts.findUnique({
where: { machine_id },
});
if (existing_host) {
return res.status(409).json({
error: "Host already exists",
host_id: existing_host.id,
api_id: existing_host.api_id,
machine_id: existing_host.machine_id,
friendly_name: existing_host.friendly_name,
message:
"This machine is already enrolled in PatchMon (matched by machine ID)",
});
}
// Create host
// Create host (no duplicate check - using config.yml checking instead)
const host = await prisma.hosts.create({
data: {
id: uuidv4(),
@@ -677,30 +768,7 @@ router.post(
try {
const { friendly_name, machine_id } = host_data;
if (!machine_id) {
results.failed.push({
friendly_name,
error: "Machine ID is required",
});
continue;
}
// Check if host already exists by machine_id
const existing_host = await prisma.hosts.findUnique({
where: { machine_id },
});
if (existing_host) {
results.skipped.push({
friendly_name,
machine_id,
reason: "Machine already enrolled",
api_id: existing_host.api_id,
});
continue;
}
// Generate credentials
// Generate credentials (no duplicate check - using config.yml checking instead)
const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`;
const api_key = crypto.randomBytes(32).toString("hex");

View File

@@ -92,58 +92,63 @@ async function createDefaultDashboardPreferences(userId, userRole = "user") {
requiredPermission: "can_view_hosts",
order: 5,
},
{
cardId: "hostsNeedingReboot",
requiredPermission: "can_view_hosts",
order: 6,
},
// Repository-related cards
{ cardId: "totalRepos", requiredPermission: "can_view_hosts", order: 6 },
{ cardId: "totalRepos", requiredPermission: "can_view_hosts", order: 7 },
// User management cards (admin only)
{ cardId: "totalUsers", requiredPermission: "can_view_users", order: 7 },
{ cardId: "totalUsers", requiredPermission: "can_view_users", order: 8 },
// System/Report cards
{
cardId: "osDistribution",
requiredPermission: "can_view_reports",
order: 8,
order: 9,
},
{
cardId: "osDistributionBar",
requiredPermission: "can_view_reports",
order: 9,
order: 10,
},
{
cardId: "osDistributionDoughnut",
requiredPermission: "can_view_reports",
order: 10,
order: 11,
},
{
cardId: "recentCollection",
requiredPermission: "can_view_hosts",
order: 11,
order: 12,
},
{
cardId: "updateStatus",
requiredPermission: "can_view_reports",
order: 12,
order: 13,
},
{
cardId: "packagePriority",
requiredPermission: "can_view_packages",
order: 13,
order: 14,
},
{
cardId: "packageTrends",
requiredPermission: "can_view_packages",
order: 14,
order: 15,
},
{
cardId: "recentUsers",
requiredPermission: "can_view_users",
order: 15,
order: 16,
},
{
cardId: "quickStats",
requiredPermission: "can_view_dashboard",
order: 16,
order: 17,
},
];
@@ -290,26 +295,33 @@ router.get("/defaults", authenticateToken, async (_req, res) => {
enabled: true,
order: 5,
},
{
cardId: "hostsNeedingReboot",
title: "Needs Reboots",
icon: "RotateCcw",
enabled: true,
order: 6,
},
{
cardId: "totalRepos",
title: "Repositories",
icon: "GitBranch",
enabled: true,
order: 6,
order: 7,
},
{
cardId: "totalUsers",
title: "Users",
icon: "Users",
enabled: true,
order: 7,
order: 8,
},
{
cardId: "osDistribution",
title: "OS Distribution",
icon: "BarChart3",
enabled: true,
order: 8,
order: 9,
},
{
cardId: "osDistributionBar",

View File

@@ -41,6 +41,7 @@ router.get(
erroredHosts,
securityUpdates,
offlineHosts,
hostsNeedingReboot,
totalHostGroups,
totalUsers,
totalRepos,
@@ -106,6 +107,13 @@ router.get(
},
}),
// Hosts needing reboot
prisma.hosts.count({
where: {
needs_reboot: true,
},
}),
// Total host groups count
prisma.host_groups.count(),
@@ -174,6 +182,7 @@ router.get(
erroredHosts,
securityUpdates,
offlineHosts,
hostsNeedingReboot,
totalHostGroups,
totalUsers,
totalRepos,
@@ -217,6 +226,7 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
auto_update: true,
notes: true,
api_id: true,
needs_reboot: true,
host_group_memberships: {
include: {
host_groups: {

View File

@@ -1,113 +1,12 @@
const express = require("express");
const { getPrismaClient } = require("../config/prisma");
const bcrypt = require("bcryptjs");
const { authenticateApiToken } = require("../middleware/apiAuth");
const router = express.Router();
const prisma = getPrismaClient();
// 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) => {
router.get("/stats", authenticateApiToken("gethomepage"), async (_req, res) => {
try {
// Get total hosts count
const totalHosts = await prisma.hosts.count({
@@ -235,7 +134,7 @@ router.get("/stats", authenticateApiKey, async (_req, res) => {
});
// Health check endpoint for the API
router.get("/health", authenticateApiKey, async (req, res) => {
router.get("/health", authenticateApiToken("gethomepage"), async (req, res) => {
res.json({
status: "ok",
timestamp: new Date().toISOString(),

View File

@@ -59,6 +59,7 @@ router.get("/:id", authenticateToken, async (req, res) => {
os_version: true,
status: true,
last_update: true,
needs_reboot: true,
},
},
},
@@ -259,6 +260,7 @@ router.get("/:id/hosts", authenticateToken, async (req, res) => {
status: true,
last_update: true,
created_at: true,
needs_reboot: true,
},
orderBy: {
friendly_name: "asc",

View File

@@ -13,6 +13,7 @@ const {
const { queueManager, QUEUE_NAMES } = require("../services/automation");
const { pushIntegrationToggle, isConnected } = require("../services/agentWs");
const agentVersionService = require("../services/agentVersionService");
const { compareVersions } = require("../services/automation/shared/utils");
const router = express.Router();
const prisma = getPrismaClient();
@@ -209,8 +210,9 @@ router.get("/agent/version", async (req, res) => {
const serverVersion = versionMatch[1];
const agentVersion = req.query.currentVersion || serverVersion;
// Simple version comparison (assuming semantic versioning)
const hasUpdate = agentVersion !== serverVersion;
// Proper semantic version comparison: only update if server version is NEWER
const hasUpdate =
compareVersions(serverVersion, agentVersion) > 0;
return res.json({
currentVersion: agentVersion,
@@ -248,9 +250,10 @@ router.get("/agent/version", async (req, res) => {
});
}
// Simple version comparison (assuming semantic versioning)
// Proper semantic version comparison: only update if latest version is NEWER
const hasUpdate =
agentVersion !== latestVersion && latestVersion !== null;
latestVersion !== null &&
compareVersions(latestVersion, agentVersion) > 0;
res.json({
currentVersion: agentVersion,
@@ -530,6 +533,14 @@ router.post(
.optional()
.isString()
.withMessage("Machine ID must be a string"),
body("needsReboot")
.optional()
.isBoolean()
.withMessage("Needs reboot must be a boolean"),
body("rebootReason")
.optional()
.isString()
.withMessage("Reboot reason must be a string"),
],
async (req, res) => {
try {
@@ -551,8 +562,11 @@ router.post(
updated_at: new Date(),
};
// Update machine_id if provided and current one is a placeholder
if (req.body.machineId && host.machine_id.startsWith("pending-")) {
// Update machine_id if provided and current one is a placeholder or null
if (
req.body.machineId &&
(host.machine_id === null || host.machine_id.startsWith("pending-"))
) {
updateData.machine_id = req.body.machineId;
}
@@ -590,6 +604,10 @@ router.post(
updateData.system_uptime = req.body.systemUptime;
if (req.body.loadAverage) updateData.load_average = req.body.loadAverage;
// Reboot Status
if (req.body.needsReboot !== undefined)
updateData.needs_reboot = req.body.needsReboot;
// If this is the first update (status is 'pending'), change to 'active'
if (host.status === "pending") {
updateData.status = "active";
@@ -1682,7 +1700,7 @@ router.get("/install", async (req, res) => {
const archExport = architecture
? `export ARCHITECTURE="${architecture}"\n`
: "";
const envVars = `#!/bin/bash
const envVars = `#!/bin/sh
export PATCHMON_URL="${serverUrl}"
export API_ID="${host.api_id}"
export API_KEY="${host.api_key}"
@@ -1708,47 +1726,7 @@ ${archExport}
}
});
// Check if machine_id already exists (requires auth)
router.post("/check-machine-id", validateApiCredentials, async (req, res) => {
try {
const { machine_id } = req.body;
if (!machine_id) {
return res.status(400).json({
error: "machine_id is required",
});
}
// Check if a host with this machine_id exists
const existing_host = await prisma.hosts.findUnique({
where: { machine_id },
select: {
id: true,
friendly_name: true,
machine_id: true,
api_id: true,
status: true,
created_at: true,
},
});
if (existing_host) {
return res.status(200).json({
exists: true,
host: existing_host,
message: "This machine is already enrolled",
});
}
return res.status(200).json({
exists: false,
message: "Machine not yet enrolled",
});
} catch (error) {
console.error("Error checking machine_id:", error);
res.status(500).json({ error: "Failed to check machine_id" });
}
});
// Note: /check-machine-id endpoint removed - using config.yml checking method instead
// Serve the removal script (public endpoint - no authentication required)
router.get("/remove", async (_req, res) => {
@@ -1781,7 +1759,7 @@ router.get("/remove", async (_req, res) => {
} catch (_) {}
// Prepend environment for CURL_FLAGS so script can use it if needed
const envPrefix = `#!/bin/bash\nexport CURL_FLAGS="${curlFlags}"\n\n`;
const envPrefix = `#!/bin/sh\nexport CURL_FLAGS="${curlFlags}"\n\n`;
script = script.replace(/^#!/, "#");
script = envPrefix + script;

View File

@@ -142,6 +142,7 @@ router.get("/", async (req, res) => {
friendly_name: true,
hostname: true,
os_type: true,
needs_reboot: true,
},
},
current_version: true,
@@ -236,6 +237,7 @@ router.get("/:packageId", async (req, res) => {
os_type: true,
os_version: true,
last_update: true,
needs_reboot: true,
},
},
},
@@ -365,6 +367,7 @@ router.get("/:packageId/hosts", async (req, res) => {
os_type: true,
os_version: true,
last_update: true,
needs_reboot: true,
},
},
},
@@ -386,6 +389,7 @@ router.get("/:packageId/hosts", async (req, res) => {
needsUpdate: hp.needs_update,
isSecurityUpdate: hp.is_security_update,
lastChecked: hp.last_checked,
needsReboot: hp.hosts.needs_reboot,
}));
res.json({

View File

@@ -119,6 +119,7 @@ router.get(
os_version: true,
status: true,
last_update: true,
needs_reboot: true,
},
},
},

View File

@@ -71,6 +71,7 @@ const wsRoutes = require("./routes/wsRoutes");
const agentVersionRoutes = require("./routes/agentVersionRoutes");
const metricsRoutes = require("./routes/metricsRoutes");
const userPreferencesRoutes = require("./routes/userPreferencesRoutes");
const apiHostsRoutes = require("./routes/apiHostsRoutes");
const { initSettings } = require("./services/settingsService");
const { queueManager } = require("./services/automation");
const { authenticateToken, requireAdmin } = require("./middleware/auth");
@@ -480,6 +481,7 @@ app.use(`/api/${apiVersion}/ws`, wsRoutes);
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes);
app.use(`/api/${apiVersion}/api`, authLimiter, apiHostsRoutes);
// Bull Board - will be populated after queue manager initializes
let bullBoardRouter = null;

View File

@@ -20,9 +20,7 @@ COPY --chown=node:node agents ./agents_backup
COPY --chown=node:node agents ./agents
COPY --chmod=755 docker/backend.docker-entrypoint.sh ./entrypoint.sh
WORKDIR /app/backend
RUN npm ci --ignore-scripts && npx prisma generate
RUN npm install --workspace=backend --ignore-scripts && cd backend && npx prisma generate
EXPOSE 3001
@@ -35,22 +33,22 @@ ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/app/entrypoint.sh"]
# Builder stage for production
FROM node:lts-alpine AS builder
# Use Debian-based Node for better QEMU ARM64 compatibility
FROM node:lts-slim AS builder
RUN apk add --no-cache openssl
# Install OpenSSL for Prisma
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --chown=node:node package*.json ./
COPY --chown=node:node backend/ ./backend/
WORKDIR /app/backend
RUN npm cache clean --force &&\
rm -rf node_modules ~/.npm /root/.npm &&\
npm ci --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=3 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 &&\
PRISMA_CLI_BINARY_TYPE=binary npm run db:generate &&\
npm prune --omit=dev &&\
npm install --workspace=backend --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=3 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 &&\
cd backend && npx prisma generate &&\
cd .. && npm prune --omit=dev --workspace=backend &&\
npm cache clean --force
# Production stage
@@ -72,8 +70,8 @@ USER node
WORKDIR /app
COPY --from=builder /app/backend ./backend
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/backend ./backend
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --chown=node:node agents ./agents_backup
COPY --chown=node:node agents ./agents
COPY --chmod=755 docker/backend.docker-entrypoint.sh ./entrypoint.sh

View File

@@ -6,7 +6,7 @@ WORKDIR /app
COPY package*.json ./
COPY frontend/ ./frontend/
RUN npm ci --ignore-scripts
RUN npm install --workspace=frontend --ignore-scripts
WORKDIR /app/frontend
@@ -15,7 +15,8 @@ EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
# Builder stage for production
FROM node:lts-alpine AS builder
# Use Debian-based Node for better QEMU ARM64 compatibility
FROM node:lts-slim AS builder
WORKDIR /app/frontend

View File

@@ -24,6 +24,9 @@ server {
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Prevent search engine indexing
add_header X-Robots-Tag "noindex, nofollow, noarchive, nosnippet" always;
# Bull Board proxy - must come before the root location to avoid conflicts
location /bullboard {

View File

@@ -6,5 +6,5 @@ VITE_API_URL=http://localhost:3001/api/v1
# Application Metadata
VITE_APP_NAME=PatchMon
VITE_APP_VERSION=1.3.1
VITE_APP_VERSION=1.3.4

View File

@@ -4,6 +4,18 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Prevent search engine indexing -->
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
<meta name="googlebot" content="noindex, nofollow, noarchive, nosnippet" />
<meta name="bingbot" content="noindex, nofollow, noarchive, nosnippet" />
<meta name="description" content="" />
<!-- Prevent caching -->
<meta http-equiv="cache-control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="pragma" content="no-cache" />
<meta http-equiv="expires" content="0" />
<title>PatchMon - Linux Patch Monitoring Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

View File

@@ -1,7 +1,7 @@
{
"name": "patchmon-frontend",
"private": true,
"version": "1.3.3",
"version": "1.3.4",
"license": "AGPL-3.0",
"type": "module",
"scripts": {

View File

@@ -0,0 +1,26 @@
# Disallow all search engine crawlers
User-agent: *
Disallow: /
# Specifically target major search engines
User-agent: Googlebot
Disallow: /
User-agent: Bingbot
Disallow: /
User-agent: Slurp
Disallow: /
User-agent: DuckDuckBot
Disallow: /
User-agent: Baiduspider
Disallow: /
User-agent: YandexBot
Disallow: /
# Prevent indexing of all content
Disallow: /*

View File

@@ -11,6 +11,12 @@ const app = express();
const PORT = process.env.PORT || 3000;
const BACKEND_URL = process.env.BACKEND_URL || "http://backend:3001";
// Add security headers to prevent search engine indexing
app.use((_req, res, next) => {
res.setHeader("X-Robots-Tag", "noindex, nofollow, noarchive, nosnippet");
next();
});
// Enable CORS for API calls
app.use(
cors({

View File

@@ -203,7 +203,7 @@ const InlineMultiGroupEdit = ({
className="mr-2 h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium text-white"
style={{ backgroundColor: option.color }}
>
{option.name}
@@ -250,7 +250,7 @@ const InlineMultiGroupEdit = ({
return (
<div className={`flex items-center gap-1 group ${className}`}>
{displayGroups.length === 0 ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-secondary-100 text-secondary-800">
Ungrouped
</span>
) : (
@@ -258,7 +258,7 @@ const InlineMultiGroupEdit = ({
{displayGroups.map((group) => (
<span
key={group.id}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium text-white"
style={{ backgroundColor: group.color }}
>
{group.name}

View File

@@ -56,7 +56,7 @@ const InlineToggle = ({
type="button"
onClick={handleToggle}
disabled={isLoading}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
className={`relative inline-flex h-5 w-9 items-center rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
value
? "bg-primary-600 dark:bg-primary-500"
: "bg-secondary-200 dark:bg-secondary-600"

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, CheckCircle, Save, Shield } from "lucide-react";
import { AlertCircle, CheckCircle, Save, Shield, X } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { permissionsAPI, settingsAPI } from "../../utils/api";
@@ -18,9 +18,60 @@ const AgentUpdatesTab = () => {
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
const [toast, setToast] = useState(null);
const queryClient = useQueryClient();
// Auto-hide toast after 3 seconds
useEffect(() => {
if (toast) {
const timer = setTimeout(() => {
setToast(null);
}, 3000);
return () => clearTimeout(timer);
}
}, [toast]);
const showToast = (message, type = "success") => {
setToast({ message, type });
};
// Fallback clipboard copy function for HTTP and older browsers
const copyToClipboard = async (text) => {
// Try modern clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.warn("Clipboard API failed, using fallback:", err);
}
}
// Fallback for HTTP or unsupported browsers
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) {
return true;
}
throw new Error("execCommand failed");
} catch (err) {
console.error("Fallback copy failed:", err);
throw err;
}
};
// Fetch current settings
const {
data: settings,
@@ -167,6 +218,53 @@ const AgentUpdatesTab = () => {
return (
<div className="space-y-6">
{/* Toast Notification */}
{toast && (
<div
className={`fixed top-4 right-4 z-50 max-w-md rounded-lg shadow-lg border-2 p-4 flex items-start space-x-3 animate-in slide-in-from-top-5 ${
toast.type === "success"
? "bg-green-50 dark:bg-green-900/90 border-green-500 dark:border-green-600"
: "bg-red-50 dark:bg-red-900/90 border-red-500 dark:border-red-600"
}`}
>
<div
className={`flex-shrink-0 rounded-full p-1 ${
toast.type === "success"
? "bg-green-100 dark:bg-green-800"
: "bg-red-100 dark:bg-red-800"
}`}
>
{toast.type === "success" ? (
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
)}
</div>
<div className="flex-1">
<p
className={`text-sm font-medium ${
toast.type === "success"
? "text-green-800 dark:text-green-100"
: "text-red-800 dark:text-red-100"
}`}
>
{toast.message}
</p>
</div>
<button
type="button"
onClick={() => setToast(null)}
className={`flex-shrink-0 rounded-lg p-1 transition-colors ${
toast.type === "success"
? "hover:bg-green-100 dark:hover:bg-green-800 text-green-600 dark:text-green-400"
: "hover:bg-red-100 dark:hover:bg-red-800 text-red-600 dark:text-red-400"
}`}
>
<X className="h-4 w-4" />
</button>
</div>
)}
{errors.general && (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
@@ -226,7 +324,7 @@ const AgentUpdatesTab = () => {
key={m}
type="button"
onClick={() => handleInputChange("updateInterval", m)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border ${
className={`px-3 py-1.5 rounded-md text-xs font-medium border ${
formData.updateInterval === m
? "bg-primary-600 text-white border-primary-600"
: "bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 border-secondary-300 dark:border-secondary-600 hover:bg-secondary-50 dark:hover:bg-secondary-600"
@@ -458,19 +556,74 @@ const AgentUpdatesTab = () => {
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
<p className="mb-3">To completely remove PatchMon from a host:</p>
{/* Go Agent Uninstall */}
{/* Agent Removal Script - Standard */}
<div className="mb-3">
<div className="space-y-2">
<div className="text-xs font-semibold text-red-700 dark:text-red-300 mb-1">
Standard Removal (preserves backups):
</div>
<div className="flex items-center gap-2">
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
sudo patchmon-agent uninstall
curl {formData.ignoreSslSelfSigned ? "-sk" : "-s"}{" "}
{window.location.origin}/api/v1/hosts/remove | sudo sh
</div>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(
"sudo patchmon-agent uninstall",
);
onClick={async () => {
try {
const curlFlags = formData.ignoreSslSelfSigned
? "-sk"
: "-s";
await copyToClipboard(
`curl ${curlFlags} ${window.location.origin}/api/v1/hosts/remove | sudo sh`,
);
showToast(
"Standard removal command copied!",
"success",
);
} catch (err) {
console.error("Failed to copy:", err);
showToast("Failed to copy to clipboard", "error");
}
}}
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
>
Copy
</button>
</div>
</div>
</div>
{/* Agent Removal Script - Complete */}
<div className="mb-3">
<div className="space-y-2">
<div className="text-xs font-semibold text-red-700 dark:text-red-300 mb-1">
Complete Removal (includes backups):
</div>
<div className="flex items-center gap-2">
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
curl {formData.ignoreSslSelfSigned ? "-sk" : "-s"}{" "}
{window.location.origin}/api/v1/hosts/remove | sudo
REMOVE_BACKUPS=1 sh
</div>
<button
type="button"
onClick={async () => {
try {
const curlFlags = formData.ignoreSslSelfSigned
? "-sk"
: "-s";
await copyToClipboard(
`curl ${curlFlags} ${window.location.origin}/api/v1/hosts/remove | sudo REMOVE_BACKUPS=1 sh`,
);
showToast(
"Complete removal command copied!",
"success",
);
} catch (err) {
console.error("Failed to copy:", err);
showToast("Failed to copy to clipboard", "error");
}
}}
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
>
@@ -478,16 +631,15 @@ const AgentUpdatesTab = () => {
</button>
</div>
<div className="text-xs text-red-600 dark:text-red-400">
Options: <code>--remove-config</code>,{" "}
<code>--remove-logs</code>, <code>--remove-all</code>,{" "}
<code>--force</code>
This removes: binaries, systemd/OpenRC services,
configuration files, logs, crontab entries, and backup files
</div>
</div>
</div>
<p className="mt-2 text-xs">
This command will remove all PatchMon files, configuration,
and crontab entries
<p className="mt-2 text-xs text-red-700 dark:text-red-400">
Standard removal preserves backup files for safety. Use
complete removal to delete everything.
</p>
</div>
</div>

View File

@@ -318,7 +318,7 @@ const RolePermissionsCard = ({
{role.role}
</h3>
{isBuiltInRole && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-primary-100 text-primary-800">
Built-in Role
</span>
)}

View File

@@ -179,7 +179,7 @@ const UsersTab = () => {
{user.username}
</div>
{user.id === currentUser?.id && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
You
</span>
)}
@@ -195,7 +195,7 @@ const UsersTab = () => {
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
className={`inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium ${
user.role === "admin"
? "bg-primary-100 text-primary-800"
: user.role === "host_manager"

View File

@@ -13,10 +13,12 @@ import {
} from "chart.js";
import {
AlertTriangle,
CheckCircle,
Folder,
GitBranch,
Package,
RefreshCw,
RotateCcw,
Server,
Settings,
Shield,
@@ -99,6 +101,20 @@ const Dashboard = () => {
navigate("/repositories");
};
const handleNeedsRebootClick = () => {
// Navigate to hosts with reboot filter, clearing any other filters
const newSearchParams = new URLSearchParams();
newSearchParams.set("reboot", "true");
navigate(`/hosts?${newSearchParams.toString()}`);
};
const handleUpToDateClick = () => {
// Navigate to hosts with upToDate filter, clearing any other filters
const newSearchParams = new URLSearchParams();
newSearchParams.set("filter", "upToDate");
navigate(`/hosts?${newSearchParams.toString()}`);
};
const _handleOSDistributionClick = () => {
navigate("/hosts?showFilters=true", { replace: true });
};
@@ -308,9 +324,10 @@ const Dashboard = () => {
[
"totalHosts",
"hostsNeedingUpdates",
"upToDateHosts",
"totalOutdatedPackages",
"securityUpdates",
"upToDateHosts",
"hostsNeedingReboot",
"totalHostGroups",
"totalUsers",
"totalRepos",
@@ -341,7 +358,7 @@ const Dashboard = () => {
const getGroupClassName = (cardType) => {
switch (cardType) {
case "stats":
return "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4";
return "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4";
case "charts":
return "grid grid-cols-1 lg:grid-cols-3 gap-6";
case "widecharts":
@@ -356,23 +373,33 @@ const Dashboard = () => {
// Helper function to render a card by ID
const renderCard = (cardId) => {
switch (cardId) {
case "upToDateHosts":
case "hostsNeedingReboot":
return (
<div className="card p-4">
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handleNeedsRebootClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleNeedsRebootClick();
}
}}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<TrendingUp className="h-5 w-5 text-success-600 mr-2" />
<RotateCcw className="h-5 w-5 text-orange-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Up to date
Needs Reboots
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.upToDateHosts}/{stats.cards.totalHosts}
{stats.cards.hostsNeedingReboot}
</p>
</div>
</div>
</div>
</button>
);
case "totalHosts":
return (
@@ -432,6 +459,35 @@ const Dashboard = () => {
</button>
);
case "upToDateHosts":
return (
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handleUpToDateClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleUpToDateClick();
}
}}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<CheckCircle className="h-5 w-5 text-success-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Up to date
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.upToDateHosts}
</p>
</div>
</div>
</button>
);
case "totalOutdatedPackages":
return (
<button

View File

@@ -21,6 +21,7 @@ import {
Monitor,
Package,
RefreshCw,
RotateCcw,
Server,
Shield,
Terminal,
@@ -493,6 +494,12 @@ const HostDetail = () => {
{getStatusIcon(isStale, host.stats.outdated_packages > 0)}
{getStatusText(isStale, host.stats.outdated_packages > 0)}
</div>
{host.needs_reboot && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
<RotateCcw className="h-3 w-3" />
Reboot Required
</span>
)}
</div>
{/* Info row with uptime and last updated */}
<div className="flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-400">
@@ -849,7 +856,7 @@ const HostDetail = () => {
toggleAutoUpdateMutation.mutate(!host.auto_update)
}
disabled={toggleAutoUpdateMutation.isPending}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
className={`relative inline-flex h-5 w-9 items-center rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
host.auto_update
? "bg-primary-600 dark:bg-primary-500"
: "bg-secondary-200 dark:bg-secondary-600"
@@ -994,7 +1001,7 @@ const HostDetail = () => {
<Terminal className="h-4 w-4 text-primary-600 dark:text-primary-400" />
System Information
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{host.architecture && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
@@ -1006,20 +1013,32 @@ const HostDetail = () => {
</div>
)}
{host.kernel_version && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
Kernel Version
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">
{host.kernel_version}
</p>
{(host.kernel_version ||
host.installed_kernel_version) && (
<div className="flex flex-col gap-2">
{host.kernel_version && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1">
Running Kernel
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
{host.kernel_version}
</p>
</div>
)}
{host.installed_kernel_version && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1">
Installed Kernel
</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm break-all">
{host.installed_kernel_version}
</p>
</div>
)}
</div>
)}
{/* Empty div to push SELinux status to the right */}
<div></div>
{host.selinux_status && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
@@ -1574,7 +1593,7 @@ const HostDetail = () => {
? "Disable Docker integration"
: "Enable Docker integration"
}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
className={`relative inline-flex h-5 w-9 items-center rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
integrationsData?.data?.integrations?.docker
? "bg-primary-600 dark:bg-primary-500"
: "bg-secondary-200 dark:bg-secondary-600"
@@ -1645,10 +1664,8 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
const [showApiKey, setShowApiKey] = useState(false);
const [activeTab, setActiveTab] = useState("quick-install");
const [forceInstall, setForceInstall] = useState(false);
const [architecture, setArchitecture] = useState("amd64");
const apiIdInputId = useId();
const apiKeyInputId = useId();
const architectureSelectId = useId();
const { data: serverUrlData } = useQuery({
queryKey: ["serverUrl"],
@@ -1668,13 +1685,13 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
};
// Helper function to build installation URL with optional force flag and architecture
// Helper function to build installation URL with optional force flag
const getInstallUrl = () => {
const baseUrl = `${serverUrl}/api/v1/hosts/install`;
const params = new URLSearchParams();
if (forceInstall) params.append("force", "true");
params.append("arch", architecture);
return `${baseUrl}?${params.toString()}`;
if (forceInstall) {
return `${baseUrl}?force=true`;
}
return baseUrl;
};
const copyToClipboard = async (text) => {
@@ -1790,34 +1807,10 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
</p>
</div>
{/* Architecture Selection */}
<div className="mb-3">
<label
htmlFor={architectureSelectId}
className="block text-sm font-medium text-primary-800 dark:text-primary-200 mb-2"
>
Target Architecture
</label>
<select
id={architectureSelectId}
value={architecture}
onChange={(e) => setArchitecture(e.target.value)}
className="px-3 py-2 border border-primary-300 dark:border-primary-600 rounded-md bg-white dark:bg-secondary-800 text-sm text-secondary-900 dark:text-white focus:ring-primary-500 focus:border-primary-500"
>
<option value="amd64">AMD64 (x86_64) - Default</option>
<option value="386">386 (i386) - 32-bit</option>
<option value="arm64">ARM64 (aarch64) - ARM 64-bit</option>
<option value="arm">ARM (armv7l/armv6l) - ARM 32-bit</option>
</select>
<p className="text-xs text-primary-600 dark:text-primary-400 mt-1">
Select the architecture of the target host
</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl ${getCurlFlags()} ${getInstallUrl()} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`}
value={`curl ${getCurlFlags()} ${getInstallUrl()} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | sh`}
readOnly
className="flex-1 px-3 py-2 border border-primary-300 dark:border-primary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
@@ -1825,7 +1818,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
type="button"
onClick={() =>
copyToClipboard(
`curl ${getCurlFlags()} ${getInstallUrl()} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | bash`,
`curl ${getCurlFlags()} ${getInstallUrl()} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" | sh`,
)
}
className="btn-primary flex items-center gap-1"
@@ -1835,270 +1828,6 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
</button>
</div>
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
Manual Installation
</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-3">
If you prefer to install manually, follow these steps:
</p>
<div className="space-y-3">
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
1. Create Configuration Directory
</h5>
<div className="flex items-center gap-2">
<input
type="text"
value="sudo mkdir -p /etc/patchmon"
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
type="button"
onClick={() =>
copyToClipboard("sudo mkdir -p /etc/patchmon")
}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
2. Download and Install Agent Binary
</h5>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl ${getCurlFlags()} -o /usr/local/bin/patchmon-agent ${serverUrl}/api/v1/hosts/agent/download?arch=${architecture} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
type="button"
onClick={() =>
copyToClipboard(
`curl ${getCurlFlags()} -o /usr/local/bin/patchmon-agent ${serverUrl}/api/v1/hosts/agent/download?arch=${architecture} -H "X-API-ID: ${host.api_id}" -H "X-API-KEY: ${host.api_key}" && sudo chmod +x /usr/local/bin/patchmon-agent`,
)
}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
3. Configure Credentials
</h5>
<div className="flex items-center gap-2">
<input
type="text"
value={`sudo /usr/local/bin/patchmon-agent config set-api "${host.api_id}" "${host.api_key}" "${serverUrl}"`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
type="button"
onClick={() =>
copyToClipboard(
`sudo /usr/local/bin/patchmon-agent config set-api "${host.api_id}" "${host.api_key}" "${serverUrl}"`,
)
}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
4. Test Configuration
</h5>
<div className="flex items-center gap-2">
<input
type="text"
value="sudo /usr/local/bin/patchmon-agent ping"
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
type="button"
onClick={() =>
copyToClipboard(
"sudo /usr/local/bin/patchmon-agent ping",
)
}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
5. Send Initial Data
</h5>
<div className="flex items-center gap-2">
<input
type="text"
value="sudo /usr/local/bin/patchmon-agent report"
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
type="button"
onClick={() =>
copyToClipboard(
"sudo /usr/local/bin/patchmon-agent report",
)
}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
6. Create Systemd Service File
</h5>
<div className="flex items-center gap-2">
<input
type="text"
value={`sudo tee /etc/systemd/system/patchmon-agent.service > /dev/null << 'EOF'
[Unit]
Description=PatchMon Agent Service
After=network.target
Wants=network.target
[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/patchmon-agent serve
Restart=always
RestartSec=10
WorkingDirectory=/etc/patchmon
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=patchmon-agent
[Install]
WantedBy=multi-user.target
EOF`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
type="button"
onClick={() =>
copyToClipboard(
`sudo tee /etc/systemd/system/patchmon-agent.service > /dev/null << 'EOF'
[Unit]
Description=PatchMon Agent Service
After=network.target
Wants=network.target
[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/patchmon-agent serve
Restart=always
RestartSec=10
WorkingDirectory=/etc/patchmon
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=patchmon-agent
[Install]
WantedBy=multi-user.target
EOF`,
)
}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
7. Enable and Start Service
</h5>
<div className="flex items-center gap-2">
<input
type="text"
value="sudo systemctl daemon-reload && sudo systemctl enable patchmon-agent && sudo systemctl start patchmon-agent"
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
type="button"
onClick={() =>
copyToClipboard(
"sudo systemctl daemon-reload && sudo systemctl enable patchmon-agent && sudo systemctl start patchmon-agent",
)
}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-2">
This will start the agent service and establish WebSocket
connection for real-time communication
</p>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
8. Verify Service Status
</h5>
<div className="flex items-center gap-2">
<input
type="text"
value="sudo systemctl status patchmon-agent"
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
type="button"
onClick={() =>
copyToClipboard("sudo systemctl status patchmon-agent")
}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-2">
Check that the service is running and WebSocket connection
is established
</p>
</div>
</div>
</div>
</div>
)}

View File

@@ -16,6 +16,7 @@ import {
GripVertical,
Plus,
RefreshCw,
RotateCcw,
Search,
Server,
Square,
@@ -247,6 +248,7 @@ const Hosts = () => {
const showFiltersParam = searchParams.get("showFilters");
const osFilterParam = searchParams.get("osFilter");
const groupParam = searchParams.get("group");
const rebootParam = searchParams.get("reboot");
if (filter === "needsUpdates") {
setShowFilters(true);
@@ -331,10 +333,11 @@ const Hosts = () => {
},
{ id: "ws_status", label: "Connection", visible: true, order: 9 },
{ id: "status", label: "Status", visible: true, order: 10 },
{ id: "updates", label: "Updates", visible: true, order: 11 },
{ id: "notes", label: "Notes", visible: false, order: 12 },
{ id: "last_update", label: "Last Update", visible: true, order: 13 },
{ id: "actions", label: "Actions", visible: true, order: 14 },
{ id: "needs_reboot", label: "Reboot", visible: true, order: 11 },
{ id: "updates", label: "Updates", visible: true, order: 12 },
{ id: "notes", label: "Notes", visible: false, order: 13 },
{ id: "last_update", label: "Last Update", visible: true, order: 14 },
{ id: "actions", label: "Actions", visible: true, order: 15 },
];
const saved = localStorage.getItem("hosts-column-config");
@@ -356,8 +359,25 @@ const Hosts = () => {
localStorage.removeItem("hosts-column-config");
return defaultConfig;
} else {
// Ensure ws_status column is visible in saved config
const updatedConfig = savedConfig.map((col) =>
// Merge saved config with defaults to handle new columns
// This preserves user's visibility preferences while adding new columns
const mergedConfig = defaultConfig.map((defaultCol) => {
const savedCol = savedConfig.find(
(col) => col.id === defaultCol.id,
);
if (savedCol) {
// Use saved visibility preference, but keep default order and label
return {
...defaultCol,
visible: savedCol.visible,
};
}
// New column not in saved config, use default
return defaultCol;
});
// Ensure ws_status column is visible
const updatedConfig = mergedConfig.map((col) =>
col.id === "ws_status" ? { ...col, visible: true } : col,
);
return updatedConfig;
@@ -673,8 +693,9 @@ const Hosts = () => {
osFilter === "all" ||
host.os_type?.toLowerCase() === osFilter.toLowerCase();
// URL filter for hosts needing updates, inactive hosts, up-to-date hosts, stale hosts, or offline hosts
// URL filter for hosts needing updates, inactive hosts, up-to-date hosts, stale hosts, offline hosts, or reboot required
const filter = searchParams.get("filter");
const rebootParam = searchParams.get("reboot");
const matchesUrlFilter =
(filter !== "needsUpdates" ||
(host.updatesCount && host.updatesCount > 0)) &&
@@ -682,7 +703,9 @@ const Hosts = () => {
(host.effectiveStatus || host.status) === "inactive") &&
(filter !== "upToDate" || (!host.isStale && host.updatesCount === 0)) &&
(filter !== "stale" || host.isStale) &&
(filter !== "offline" || wsStatusMap[host.api_id]?.connected !== true);
(filter !== "offline" ||
wsStatusMap[host.api_id]?.connected !== true) &&
(!rebootParam || host.needs_reboot === true);
// Hide stale filter
const matchesHideStale = !hideStale || !host.isStale;
@@ -758,6 +781,11 @@ const Hosts = () => {
aValue = a.updatesCount || 0;
bValue = b.updatesCount || 0;
break;
case "needs_reboot":
// Sort by boolean: false (0) comes before true (1)
aValue = a.needs_reboot ? 1 : 0;
bValue = b.needs_reboot ? 1 : 0;
break;
case "last_update":
aValue = new Date(a.last_update);
bValue = new Date(b.last_update);
@@ -917,10 +945,11 @@ const Hosts = () => {
},
{ id: "ws_status", label: "Connection", visible: true, order: 9 },
{ id: "status", label: "Status", visible: true, order: 10 },
{ id: "updates", label: "Updates", visible: true, order: 11 },
{ id: "notes", label: "Notes", visible: false, order: 12 },
{ id: "last_update", label: "Last Update", visible: true, order: 13 },
{ id: "actions", label: "Actions", visible: true, order: 14 },
{ id: "needs_reboot", label: "Reboot", visible: true, order: 11 },
{ id: "updates", label: "Updates", visible: true, order: 12 },
{ id: "notes", label: "Notes", visible: false, order: 13 },
{ id: "last_update", label: "Last Update", visible: true, order: 14 },
{ id: "actions", label: "Actions", visible: true, order: 15 },
];
updateColumnConfig(defaultConfig);
};
@@ -1042,7 +1071,7 @@ const Hosts = () => {
const wsStatus = wsStatusMap[host.api_id];
if (!wsStatus) {
return (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
<div className="w-2 h-2 bg-gray-400 rounded-full mr-1.5"></div>
Unknown
</span>
@@ -1050,7 +1079,7 @@ const Hosts = () => {
}
return (
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${
wsStatus.connected
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"
@@ -1077,6 +1106,22 @@ const Hosts = () => {
(host.effectiveStatus || host.status).slice(1)}
</div>
);
case "needs_reboot":
return (
<div className="flex justify-center">
{host.needs_reboot ? (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
<RotateCcw className="h-3 w-3" />
Required
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<CheckCircle className="h-3 w-3" />
No
</span>
)}
</div>
);
case "updates":
return (
<button
@@ -1149,9 +1194,10 @@ const Hosts = () => {
// Filter to show only up-to-date hosts
setStatusFilter("active");
setShowFilters(true);
// Use the upToDate URL filter
// Clear conflicting filters and set upToDate filter
const newSearchParams = new URLSearchParams(window.location.search);
newSearchParams.set("filter", "upToDate");
newSearchParams.delete("reboot"); // Clear reboot filter when switching to upToDate
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
};
@@ -1159,9 +1205,10 @@ const Hosts = () => {
// Filter to show hosts needing updates (regardless of status)
setStatusFilter("all");
setShowFilters(true);
// We'll use the existing needsUpdates URL filter logic
// Clear conflicting filters and set needsUpdates filter
const newSearchParams = new URLSearchParams(window.location.search);
newSearchParams.set("filter", "needsUpdates");
newSearchParams.delete("reboot"); // Clear reboot filter when switching to needsUpdates
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
};
@@ -1169,9 +1216,10 @@ const Hosts = () => {
// Filter to show offline hosts (not connected via WebSocket)
setStatusFilter("all");
setShowFilters(true);
// Use a new URL filter for connection status
// Clear conflicting filters and set offline filter
const newSearchParams = new URLSearchParams(window.location.search);
newSearchParams.set("filter", "offline");
newSearchParams.delete("reboot"); // Clear reboot filter when switching to offline
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
};
@@ -1262,24 +1310,6 @@ const Hosts = () => {
</div>
</div>
</button>
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
onClick={handleUpToDateClick}
>
<div className="flex items-center">
<CheckCircle className="h-5 w-5 text-success-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Up to Date
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{hosts?.filter((h) => !h.isStale && h.updatesCount === 0)
.length || 0}
</p>
</div>
</div>
</button>
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
@@ -1297,6 +1327,28 @@ const Hosts = () => {
</div>
</div>
</button>
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
onClick={() => {
const newSearchParams = new URLSearchParams();
newSearchParams.set("reboot", "true");
// Clear filter parameter when setting reboot filter
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true });
}}
>
<div className="flex items-center">
<RotateCcw className="h-5 w-5 text-orange-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Needs Reboots
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{hosts?.filter((h) => h.needs_reboot === true).length || 0}
</p>
</div>
</div>
</button>
<button
type="button"
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
@@ -1679,6 +1731,17 @@ const Hosts = () => {
{column.label}
{getSortIcon("updates")}
</button>
) : column.id === "needs_reboot" ? (
<button
type="button"
onClick={() =>
handleSort("needs_reboot")
}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon("needs_reboot")}
</button>
) : column.id === "last_update" ? (
<button
type="button"

View File

@@ -8,6 +8,7 @@ import {
Download,
Package,
RefreshCw,
RotateCcw,
Search,
Server,
Shield,
@@ -370,6 +371,9 @@ const PackageDetail = () => {
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Last Updated
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Reboot Required
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
@@ -420,6 +424,18 @@ const PackageDetail = () => {
? formatRelativeTime(host.lastUpdate)
: "Never"}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{host.needsReboot ? (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
<RotateCcw className="h-3 w-3" />
Required
</span>
) : (
<span className="text-sm text-secondary-500 dark:text-secondary-300">
No
</span>
)}
</td>
</tr>
))}
</tbody>

View File

@@ -206,7 +206,7 @@ const Profile = () => {
</p>
<div className="mt-2">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
className={`inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium ${
user?.role === "admin"
? "bg-primary-100 text-primary-800"
: user?.role === "host_manager"
@@ -400,7 +400,7 @@ const Profile = () => {
<button
type="button"
onClick={toggleTheme}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-md border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
isDark ? "bg-primary-600" : "bg-secondary-200"
}`}
role="switch"
@@ -1345,12 +1345,12 @@ const SessionsTab = () => {
{session.device_info?.os}
</h4>
{session.is_current_session && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
Current Session
</span>
)}
{session.tfa_remember_me && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-200">
<span className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-success-100 text-success-800 dark:bg-success-900 dark:text-success-200">
Remembered
</span>
)}

View File

@@ -6,6 +6,7 @@ import {
Database,
Globe,
Lock,
RotateCcw,
Search,
Server,
Shield,
@@ -556,6 +557,9 @@ const RepositoryDetail = () => {
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Last Update
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Reboot Required
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
@@ -604,6 +608,18 @@ const RepositoryDetail = () => {
? formatRelativeTime(hostRepo.hosts.last_update)
: "Never"}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{hostRepo.hosts.needs_reboot ? (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
<RotateCcw className="h-3 w-3" />
Required
</span>
) : (
<span className="text-sm text-secondary-500 dark:text-secondary-300">
No
</span>
)}
</td>
</tr>
))}
</tbody>

View File

@@ -53,6 +53,7 @@ const Settings = () => {
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
const [toast, setToast] = useState(null);
// Tab management
const [activeTab, setActiveTab] = useState("server");
@@ -60,6 +61,56 @@ const Settings = () => {
// Get update notification state
const { updateAvailable } = useUpdateNotification();
// Auto-hide toast after 3 seconds
useEffect(() => {
if (toast) {
const timer = setTimeout(() => {
setToast(null);
}, 3000);
return () => clearTimeout(timer);
}
}, [toast]);
const showToast = (message, type = "success") => {
setToast({ message, type });
};
// Fallback clipboard copy function for HTTP and older browsers
const copyToClipboard = async (text) => {
// Try modern clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.warn("Clipboard API failed, using fallback:", err);
}
}
// Fallback for HTTP or unsupported browsers
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) {
return true;
}
throw new Error("execCommand failed");
} catch (err) {
console.error("Fallback copy failed:", err);
throw err;
}
};
// Tab configuration
const tabs = [
{ id: "server", name: "Server Configuration", icon: Server },
@@ -120,7 +171,7 @@ const Settings = () => {
});
// Helper function to get curl flags based on settings
const _getCurlFlags = () => {
const getCurlFlags = () => {
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
};
@@ -442,6 +493,53 @@ const Settings = () => {
return (
<div className="max-w-4xl mx-auto p-6">
{/* Toast Notification */}
{toast && (
<div
className={`fixed top-4 right-4 z-50 max-w-md rounded-lg shadow-lg border-2 p-4 flex items-start space-x-3 animate-in slide-in-from-top-5 ${
toast.type === "success"
? "bg-green-50 dark:bg-green-900/90 border-green-500 dark:border-green-600"
: "bg-red-50 dark:bg-red-900/90 border-red-500 dark:border-red-600"
}`}
>
<div
className={`flex-shrink-0 rounded-full p-1 ${
toast.type === "success"
? "bg-green-100 dark:bg-green-800"
: "bg-red-100 dark:bg-red-800"
}`}
>
{toast.type === "success" ? (
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
)}
</div>
<div className="flex-1">
<p
className={`text-sm font-medium ${
toast.type === "success"
? "text-green-800 dark:text-green-100"
: "text-red-800 dark:text-red-100"
}`}
>
{toast.message}
</p>
</div>
<button
type="button"
onClick={() => setToast(null)}
className={`flex-shrink-0 rounded-lg p-1 transition-colors ${
toast.type === "success"
? "hover:bg-green-100 dark:hover:bg-green-800 text-green-600 dark:text-green-400"
: "hover:bg-red-100 dark:hover:bg-red-800 text-red-600 dark:text-red-400"
}`}
>
<X className="h-4 w-4" />
</button>
</div>
)}
<div className="mb-8">
<p className="text-secondary-600 dark:text-secondary-300">
Configure your PatchMon server settings. These settings will be used
@@ -819,7 +917,7 @@ const Settings = () => {
key={m}
type="button"
onClick={() => handleInputChange("updateInterval", m)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border ${
className={`px-3 py-1.5 rounded-md text-xs font-medium border ${
formData.updateInterval === m
? "bg-primary-600 text-white border-primary-600"
: "bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 border-secondary-300 dark:border-secondary-600 hover:bg-secondary-50 dark:hover:bg-secondary-600"
@@ -1159,19 +1257,74 @@ const Settings = () => {
To completely remove PatchMon from a host:
</p>
{/* Go Agent Uninstall */}
{/* Agent Removal Script - Standard */}
<div className="mb-3">
<div className="space-y-2">
<div className="text-xs font-semibold text-red-700 dark:text-red-300 mb-1">
Standard Removal (preserves backups):
</div>
<div className="flex items-center gap-2">
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
sudo patchmon-agent uninstall
curl {getCurlFlags()} {window.location.origin}
/api/v1/hosts/remove | sudo sh
</div>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(
"sudo patchmon-agent uninstall",
);
onClick={async () => {
try {
await copyToClipboard(
`curl ${getCurlFlags()} ${window.location.origin}/api/v1/hosts/remove | sudo sh`,
);
showToast(
"Standard removal command copied!",
"success",
);
} catch (err) {
console.error("Failed to copy:", err);
showToast(
"Failed to copy to clipboard",
"error",
);
}
}}
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
>
Copy
</button>
</div>
</div>
</div>
{/* Agent Removal Script - Complete */}
<div className="mb-3">
<div className="space-y-2">
<div className="text-xs font-semibold text-red-700 dark:text-red-300 mb-1">
Complete Removal (includes backups):
</div>
<div className="flex items-center gap-2">
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
curl {getCurlFlags()} {window.location.origin}
/api/v1/hosts/remove | sudo REMOVE_BACKUPS=1
sh
</div>
<button
type="button"
onClick={async () => {
try {
await copyToClipboard(
`curl ${getCurlFlags()} ${window.location.origin}/api/v1/hosts/remove | sudo REMOVE_BACKUPS=1 sh`,
);
showToast(
"Complete removal command copied!",
"success",
);
} catch (err) {
console.error("Failed to copy:", err);
showToast(
"Failed to copy to clipboard",
"error",
);
}
}}
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
>
@@ -1179,16 +1332,16 @@ const Settings = () => {
</button>
</div>
<div className="text-xs text-red-600 dark:text-red-400">
Options: <code>--remove-config</code>,{" "}
<code>--remove-logs</code>,{" "}
<code>--remove-all</code>, <code>--force</code>
This removes: binaries, systemd/OpenRC services,
configuration files, logs, crontab entries, and
backup files
</div>
</div>
</div>
<p className="mt-2 text-xs">
This command will remove all PatchMon files,
configuration, and crontab entries
<p className="mt-2 text-xs text-red-700 dark:text-red-400">
Standard removal preserves backup files for
safety. Use complete removal to delete everything.
</p>
</div>
</div>

View File

@@ -32,7 +32,7 @@ const AlertChannels = () => {
including email, Slack, Discord, and webhooks.
</p>
<div className="mt-3">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
In Development
</span>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,7 @@ const Notifications = () => {
triggers.
</p>
<div className="mt-3">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
In Development
</span>
</div>

View File

@@ -34,7 +34,7 @@ const PatchManagement = () => {
patch policies to give you granular control over updates.
</p>
<div className="mt-3">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
In Development
</span>
</div>

View File

@@ -212,7 +212,7 @@ const SettingsMetrics = () => {
toggleMetricsMutation.mutate(!metricsSettings?.metrics_enabled)
}
disabled={toggleMetricsMutation.isPending}
className={`ml-4 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
className={`ml-4 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-md border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
metricsSettings?.metrics_enabled
? "bg-primary-600"
: "bg-secondary-200 dark:bg-secondary-700"

8
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "patchmon",
"version": "1.3.2",
"version": "1.3.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "patchmon",
"version": "1.3.2",
"version": "1.3.4",
"license": "AGPL-3.0",
"workspaces": [
"backend",
@@ -23,7 +23,7 @@
},
"backend": {
"name": "patchmon-backend",
"version": "1.3.2",
"version": "1.3.4",
"license": "AGPL-3.0",
"dependencies": {
"@bull-board/api": "^6.13.1",
@@ -59,7 +59,7 @@
},
"frontend": {
"name": "patchmon-frontend",
"version": "1.3.2",
"version": "1.3.4",
"license": "AGPL-3.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon",
"version": "1.3.3",
"version": "1.3.4",
"description": "Linux Patch Monitoring System",
"license": "AGPL-3.0",
"private": true,