mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-11 01:16:12 +00:00
Compare commits
3 Commits
feature/ap
...
renovate/b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ba6ae9f25 | ||
|
|
427743b81e | ||
|
|
a4922b4e54 |
@@ -1,32 +1,7 @@
|
||||
#!/bin/sh
|
||||
# PatchMon Agent Installation Script
|
||||
# This script requires bash for full functionality
|
||||
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/install -H "X-API-ID: {API_ID}" -H "X-API-KEY: {API_KEY}" | sh
|
||||
|
||||
# Check if bash is available, if not try to install it (for Alpine Linux)
|
||||
if ! command -v bash >/dev/null 2>&1; then
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
echo "Installing bash for script compatibility..."
|
||||
apk add --no-cache bash >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# If bash is available and we're not already running in bash, switch to bash
|
||||
# When piped, we can't re-execute easily, so we'll continue with sh
|
||||
# but ensure bash is available for bash-specific features
|
||||
if command -v bash >/dev/null 2>&1 && [ -z "${BASH_VERSION:-}" ]; then
|
||||
# Check if we're being piped (stdin is not a terminal)
|
||||
if [ -t 0 ]; then
|
||||
# Direct execution, re-execute with bash
|
||||
exec bash "$0" "$@"
|
||||
exit $?
|
||||
fi
|
||||
# When piped, we continue with sh but bash is now available
|
||||
# The script will use bash-specific features which should work if bash is installed
|
||||
fi
|
||||
#!/bin/bash
|
||||
|
||||
# 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}" | sh
|
||||
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/install -H "X-API-ID: {API_ID}" -H "X-API-KEY: {API_KEY}" | bash
|
||||
|
||||
set -e
|
||||
|
||||
@@ -61,7 +36,7 @@ warning() {
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
error "This script must be run as root (use sudo)"
|
||||
fi
|
||||
|
||||
@@ -70,8 +45,8 @@ verify_datetime() {
|
||||
info "🕐 Verifying system datetime and timezone..."
|
||||
|
||||
# Get current system time
|
||||
system_time=$(date)
|
||||
timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
|
||||
local system_time=$(date)
|
||||
local timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
|
||||
|
||||
# Display current datetime info
|
||||
echo ""
|
||||
@@ -81,17 +56,14 @@ verify_datetime() {
|
||||
echo ""
|
||||
|
||||
# Check if we can read from stdin (interactive terminal)
|
||||
if [ -t 0 ]; then
|
||||
if [[ -t 0 ]]; then
|
||||
# Interactive terminal - ask user
|
||||
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
|
||||
;;
|
||||
*)
|
||||
read -p "Does this date/time look correct to you? (y/N): " -r response
|
||||
if [[ "$response" =~ ^[Yy]$ ]]; then
|
||||
success "✅ Date/time verification passed"
|
||||
echo ""
|
||||
return 0
|
||||
else
|
||||
echo ""
|
||||
echo -e "${RED}❌ Date/time verification failed${NC}"
|
||||
echo ""
|
||||
@@ -100,10 +72,9 @@ verify_datetime() {
|
||||
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"
|
||||
;;
|
||||
esac
|
||||
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
|
||||
else
|
||||
# Non-interactive (piped from curl) - show warning and continue
|
||||
echo -e "${YELLOW}⚠️ Non-interactive installation detected${NC}"
|
||||
@@ -150,9 +121,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)
|
||||
@@ -161,12 +132,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
|
||||
@@ -191,16 +162,13 @@ if [ -z "$ARCHITECTURE" ]; then
|
||||
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}"
|
||||
case "$*" in
|
||||
*"--force"*) FORCE_INSTALL="true" ;;
|
||||
esac
|
||||
if [ "$FORCE_INSTALL" = "true" ]; then
|
||||
if [[ "$*" == *"--force"* ]] || [[ "$FORCE_INSTALL" == "true" ]]; then
|
||||
FORCE_INSTALL="true"
|
||||
warning "⚠️ Force mode enabled - will bypass broken packages"
|
||||
fi
|
||||
@@ -256,7 +224,7 @@ install_apt_packages() {
|
||||
# Build apt-get command based on force mode
|
||||
local 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\""
|
||||
fi
|
||||
@@ -272,7 +240,7 @@ install_apt_packages() {
|
||||
local 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"
|
||||
@@ -311,7 +279,7 @@ install_yum_dnf_packages() {
|
||||
|
||||
info "Need to install: ${missing_packages[*]}"
|
||||
|
||||
if [ "$pkg_manager" = "yum" ]; then
|
||||
if [[ "$pkg_manager" == "yum" ]]; then
|
||||
yum install -y "${missing_packages[@]}"
|
||||
else
|
||||
dnf install -y "${missing_packages[@]}"
|
||||
@@ -397,7 +365,7 @@ install_apk_packages() {
|
||||
local 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"
|
||||
@@ -423,7 +391,7 @@ 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"
|
||||
@@ -478,7 +446,7 @@ echo ""
|
||||
info "📁 Setting up configuration directory..."
|
||||
|
||||
# Check if configuration directory already exists
|
||||
if [ -d "/etc/patchmon" ]; then
|
||||
if [[ -d "/etc/patchmon" ]]; then
|
||||
warning "⚠️ Configuration directory already exists at /etc/patchmon"
|
||||
warning "⚠️ Preserving existing configuration files"
|
||||
|
||||
@@ -495,8 +463,8 @@ fi
|
||||
# Check if agent is already configured and working (before we overwrite anything)
|
||||
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
|
||||
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..."
|
||||
|
||||
@@ -527,7 +495,7 @@ fi
|
||||
info "🔐 Creating configuration files..."
|
||||
|
||||
# Check if config file already exists
|
||||
if [ -f "/etc/patchmon/config.yml" ]; then
|
||||
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"
|
||||
|
||||
@@ -540,7 +508,7 @@ if [ -f "/etc/patchmon/config.yml" ]; then
|
||||
fi
|
||||
|
||||
# Check if credentials file already exists
|
||||
if [ -f "/etc/patchmon/credentials.yml" ]; then
|
||||
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"
|
||||
|
||||
@@ -553,7 +521,7 @@ if [ -f "/etc/patchmon/credentials.yml" ]; then
|
||||
fi
|
||||
|
||||
# Clean up old credentials file if it exists (from previous installations)
|
||||
if [ -f "/etc/patchmon/credentials" ]; then
|
||||
if [[ -f "/etc/patchmon/credentials" ]]; then
|
||||
warning "⚠️ Found old credentials file, removing it..."
|
||||
rm -f /etc/patchmon/credentials
|
||||
info "📋 Removed old credentials file"
|
||||
@@ -589,7 +557,7 @@ info "📥 Downloading PatchMon agent binary..."
|
||||
BINARY_NAME="patchmon-agent-linux-${ARCHITECTURE}"
|
||||
|
||||
# Check if agent binary already exists
|
||||
if [ -f "/usr/local/bin/patchmon-agent" ]; then
|
||||
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"
|
||||
|
||||
@@ -602,7 +570,7 @@ if [ -f "/usr/local/bin/patchmon-agent" ]; then
|
||||
fi
|
||||
|
||||
# Clean up old shell script if it exists (from previous installations)
|
||||
if [ -f "/usr/local/bin/patchmon-agent.sh" ]; then
|
||||
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"
|
||||
@@ -628,7 +596,7 @@ info "📁 Setting up log directory..."
|
||||
mkdir -p /etc/patchmon/logs
|
||||
|
||||
# Handle existing log files
|
||||
if [ -f "/etc/patchmon/logs/patchmon-agent.log" ]; then
|
||||
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"
|
||||
|
||||
@@ -645,26 +613,23 @@ else
|
||||
error "❌ Failed to validate API credentials or reach server"
|
||||
fi
|
||||
|
||||
# Step 5: Setup service for WebSocket connection
|
||||
# Step 5: Setup systemd service for WebSocket connection
|
||||
# Note: The service will automatically send an initial report on startup (see serve.go)
|
||||
# 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
|
||||
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
|
||||
@@ -686,105 +651,25 @@ 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
|
||||
|
||||
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"
|
||||
# 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
|
||||
|
||||
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
|
||||
# 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"
|
||||
# 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"
|
||||
|
||||
SERVICE_TYPE="crontab"
|
||||
else
|
||||
warning "⚠️ Service may have failed to start. Check status with: systemctl status patchmon-agent"
|
||||
fi
|
||||
|
||||
# Installation complete
|
||||
@@ -795,20 +680,14 @@ echo " • Configuration directory: /etc/patchmon"
|
||||
echo " • Agent binary installed: /usr/local/bin/patchmon-agent"
|
||||
echo " • Architecture: $ARCHITECTURE"
|
||||
echo " • Dependencies installed: jq, curl, bc"
|
||||
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 " • Systemd service configured and running"
|
||||
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}"
|
||||
echo "$MOVED_FILES" | while read -r moved_file; do
|
||||
@@ -823,17 +702,8 @@ echo -e "${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"
|
||||
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 " • Service status: systemctl status patchmon-agent"
|
||||
echo " • Service logs: journalctl -u patchmon-agent -f"
|
||||
echo " • Restart service: systemctl restart patchmon-agent"
|
||||
echo ""
|
||||
success "✅ Your system is now being monitored by PatchMon!"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@bull-board/express": "^6.13.1",
|
||||
"@prisma/client": "^6.1.0",
|
||||
"axios": "^1.7.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bcryptjs": "^3.0.0",
|
||||
"bullmq": "^5.61.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
@@ -37,7 +37,7 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"nodemon": "^3.1.9",
|
||||
"prisma": "^6.1.0"
|
||||
},
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "auto_enrollment_tokens" ADD COLUMN "scopes" JSONB;
|
||||
|
||||
@@ -288,7 +288,6 @@ 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)
|
||||
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
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 };
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -1,143 +0,0 @@
|
||||
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;
|
||||
@@ -125,10 +125,6 @@ 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 {
|
||||
@@ -144,7 +140,6 @@ router.post(
|
||||
default_host_group_id,
|
||||
expires_at,
|
||||
metadata = {},
|
||||
scopes,
|
||||
} = req.body;
|
||||
|
||||
// Validate host group if provided
|
||||
@@ -158,32 +153,6 @@ 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);
|
||||
|
||||
@@ -199,7 +168,6 @@ 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: {
|
||||
@@ -233,7 +201,6 @@ 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!",
|
||||
});
|
||||
@@ -265,7 +232,6 @@ router.get(
|
||||
created_at: true,
|
||||
default_host_group_id: true,
|
||||
metadata: true,
|
||||
scopes: true,
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -348,10 +314,6 @@ 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 {
|
||||
@@ -361,16 +323,6 @@ 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)
|
||||
@@ -382,41 +334,6 @@ 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,
|
||||
|
||||
@@ -1,12 +1,113 @@
|
||||
const express = require("express");
|
||||
const { getPrismaClient } = require("../config/prisma");
|
||||
const { authenticateApiToken } = require("../middleware/apiAuth");
|
||||
const bcrypt = require("bcryptjs");
|
||||
|
||||
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", authenticateApiToken("gethomepage"), async (_req, res) => {
|
||||
router.get("/stats", authenticateApiKey, async (_req, res) => {
|
||||
try {
|
||||
// Get total hosts count
|
||||
const totalHosts = await prisma.hosts.count({
|
||||
@@ -134,7 +235,7 @@ router.get("/stats", authenticateApiToken("gethomepage"), async (_req, res) => {
|
||||
});
|
||||
|
||||
// Health check endpoint for the API
|
||||
router.get("/health", authenticateApiToken("gethomepage"), async (req, res) => {
|
||||
router.get("/health", authenticateApiKey, async (req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
@@ -71,7 +71,6 @@ 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");
|
||||
@@ -481,7 +480,6 @@ 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;
|
||||
|
||||
@@ -28,8 +28,6 @@ const Integrations = () => {
|
||||
const [host_groups, setHostGroups] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [show_create_modal, setShowCreateModal] = useState(false);
|
||||
const [show_edit_modal, setShowEditModal] = useState(false);
|
||||
const [edit_token, setEditToken] = useState(null);
|
||||
const [new_token, setNewToken] = useState(null);
|
||||
const [show_secret, setShowSecret] = useState(false);
|
||||
const [server_url, setServerUrl] = useState("");
|
||||
@@ -42,9 +40,6 @@ const Integrations = () => {
|
||||
default_host_group_id: "",
|
||||
allowed_ip_ranges: "",
|
||||
expires_at: "",
|
||||
scopes: {
|
||||
host: [],
|
||||
},
|
||||
});
|
||||
|
||||
const [copy_success, setCopySuccess] = useState({});
|
||||
@@ -59,25 +54,6 @@ const Integrations = () => {
|
||||
setActiveTab(tabName);
|
||||
};
|
||||
|
||||
const toggle_scope_action = (resource, action) => {
|
||||
setFormData((prev) => {
|
||||
const current_scopes = prev.scopes || { [resource]: [] };
|
||||
const resource_scopes = current_scopes[resource] || [];
|
||||
|
||||
const updated_scopes = resource_scopes.includes(action)
|
||||
? resource_scopes.filter((a) => a !== action)
|
||||
: [...resource_scopes, action];
|
||||
|
||||
return {
|
||||
...prev,
|
||||
scopes: {
|
||||
...current_scopes,
|
||||
[resource]: updated_scopes,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on mount
|
||||
useEffect(() => {
|
||||
load_tokens();
|
||||
@@ -120,14 +96,6 @@ const Integrations = () => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
// Determine integration type based on active tab
|
||||
let integration_type = "proxmox-lxc";
|
||||
if (activeTab === "gethomepage") {
|
||||
integration_type = "gethomepage";
|
||||
} else if (activeTab === "api") {
|
||||
integration_type = "api";
|
||||
}
|
||||
|
||||
const data = {
|
||||
token_name: form_data.token_name,
|
||||
max_hosts_per_day: Number.parseInt(form_data.max_hosts_per_day, 10),
|
||||
@@ -135,7 +103,8 @@ const Integrations = () => {
|
||||
? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim())
|
||||
: [],
|
||||
metadata: {
|
||||
integration_type: integration_type,
|
||||
integration_type:
|
||||
activeTab === "gethomepage" ? "gethomepage" : "proxmox-lxc",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -147,11 +116,6 @@ const Integrations = () => {
|
||||
data.expires_at = form_data.expires_at;
|
||||
}
|
||||
|
||||
// Add scopes for API credentials
|
||||
if (activeTab === "api" && form_data.scopes) {
|
||||
data.scopes = form_data.scopes;
|
||||
}
|
||||
|
||||
const response = await api.post("/auto-enrollment/tokens", data);
|
||||
setNewToken(response.data.token);
|
||||
setShowCreateModal(false);
|
||||
@@ -164,9 +128,6 @@ const Integrations = () => {
|
||||
default_host_group_id: "",
|
||||
allowed_ip_ranges: "",
|
||||
expires_at: "",
|
||||
scopes: {
|
||||
host: [],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create token:", error);
|
||||
@@ -207,69 +168,6 @@ const Integrations = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const open_edit_modal = (token) => {
|
||||
setEditToken(token);
|
||||
setFormData({
|
||||
token_name: token.token_name,
|
||||
max_hosts_per_day: token.max_hosts_per_day || 100,
|
||||
default_host_group_id: token.default_host_group_id || "",
|
||||
allowed_ip_ranges: token.allowed_ip_ranges?.join(", ") || "",
|
||||
expires_at: token.expires_at
|
||||
? new Date(token.expires_at).toISOString().slice(0, 16)
|
||||
: "",
|
||||
scopes: token.scopes || { host: [] },
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const update_token = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const data = {
|
||||
allowed_ip_ranges: form_data.allowed_ip_ranges
|
||||
? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim())
|
||||
: [],
|
||||
};
|
||||
|
||||
// Add expiration if provided
|
||||
if (form_data.expires_at) {
|
||||
data.expires_at = form_data.expires_at;
|
||||
}
|
||||
|
||||
// Add scopes for API credentials
|
||||
if (
|
||||
edit_token?.metadata?.integration_type === "api" &&
|
||||
form_data.scopes
|
||||
) {
|
||||
data.scopes = form_data.scopes;
|
||||
}
|
||||
|
||||
await api.patch(`/auto-enrollment/tokens/${edit_token.id}`, data);
|
||||
setShowEditModal(false);
|
||||
setEditToken(null);
|
||||
load_tokens();
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
token_name: "",
|
||||
max_hosts_per_day: 100,
|
||||
default_host_group_id: "",
|
||||
allowed_ip_ranges: "",
|
||||
expires_at: "",
|
||||
scopes: {
|
||||
host: [],
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to update token:", error);
|
||||
const error_message = error.response?.data?.errors
|
||||
? error.response.data.errors.map((e) => e.msg).join(", ")
|
||||
: error.response?.data?.error || "Failed to update token";
|
||||
alert(error_message);
|
||||
}
|
||||
};
|
||||
|
||||
const copy_to_clipboard = async (text, key) => {
|
||||
// Check if Clipboard API is available
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
@@ -358,17 +256,6 @@ const Integrations = () => {
|
||||
>
|
||||
GetHomepage
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("api")}
|
||||
className={`px-6 py-3 text-sm font-medium ${
|
||||
activeTab === "api"
|
||||
? "text-primary-600 dark:text-primary-400 border-b-2 border-primary-500 bg-primary-50 dark:bg-primary-900/20"
|
||||
: "text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
|
||||
}`}
|
||||
>
|
||||
API
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabChange("docker")}
|
||||
@@ -849,214 +736,6 @@ const Integrations = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Tab */}
|
||||
{activeTab === "api" && (
|
||||
<div className="space-y-6">
|
||||
{/* Header with New Credential Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-primary-100 dark:bg-primary-900 rounded-lg flex items-center justify-center">
|
||||
<Server className="h-5 w-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
API Credentials
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
Manage API credentials for programmatic access to
|
||||
PatchMon data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Credential
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* API Credentials List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
||||
</div>
|
||||
) : tokens.filter(
|
||||
(token) => token.metadata?.integration_type === "api",
|
||||
).length === 0 ? (
|
||||
<div className="text-center py-8 text-secondary-600 dark:text-secondary-400">
|
||||
<p>No API credentials created yet.</p>
|
||||
<p className="text-sm mt-2">
|
||||
Create a credential to enable programmatic access to
|
||||
PatchMon.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tokens
|
||||
.filter(
|
||||
(token) => token.metadata?.integration_type === "api",
|
||||
)
|
||||
.map((token) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="border border-secondary-200 dark:border-secondary-600 rounded-lg p-4 hover:border-primary-300 dark:hover:border-primary-700 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-medium text-secondary-900 dark:text-white">
|
||||
{token.token_name}
|
||||
</h4>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
API
|
||||
</span>
|
||||
{token.is_active ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-sm text-secondary-600 dark:text-secondary-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs bg-secondary-100 dark:bg-secondary-700 px-2 py-1 rounded">
|
||||
{token.token_key}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(
|
||||
token.token_key,
|
||||
`key-${token.id}`,
|
||||
)
|
||||
}
|
||||
className="text-primary-600 hover:text-primary-700 dark:text-primary-400"
|
||||
>
|
||||
{copy_success[`key-${token.id}`] ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{token.scopes && (
|
||||
<p>
|
||||
Scopes:{" "}
|
||||
{Object.entries(token.scopes)
|
||||
.map(
|
||||
([resource, actions]) =>
|
||||
`${resource}: ${Array.isArray(actions) ? actions.join(", ") : actions}`,
|
||||
)
|
||||
.join(" | ")}
|
||||
</p>
|
||||
)}
|
||||
{token.allowed_ip_ranges?.length > 0 && (
|
||||
<p>
|
||||
Allowed IPs:{" "}
|
||||
{token.allowed_ip_ranges.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
<p>Created: {format_date(token.created_at)}</p>
|
||||
{token.last_used_at && (
|
||||
<p>
|
||||
Last Used: {format_date(token.last_used_at)}
|
||||
</p>
|
||||
)}
|
||||
{token.expires_at && (
|
||||
<p>
|
||||
Expires: {format_date(token.expires_at)}
|
||||
{new Date(token.expires_at) <
|
||||
new Date() && (
|
||||
<span className="ml-2 text-red-600 dark:text-red-400">
|
||||
(Expired)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => open_edit_modal(token)}
|
||||
className="px-3 py-1 text-sm rounded bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
toggle_token_active(token.id, token.is_active)
|
||||
}
|
||||
className={`px-3 py-1 text-sm rounded ${
|
||||
token.is_active
|
||||
? "bg-secondary-100 text-secondary-700 hover:bg-secondary-200 dark:bg-secondary-700 dark:text-secondary-300"
|
||||
: "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900 dark:text-green-300"
|
||||
}`}
|
||||
>
|
||||
{token.is_active ? "Disable" : "Enable"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
delete_token(token.id, token.token_name)
|
||||
}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 p-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200 mb-4">
|
||||
Using API Credentials
|
||||
</h3>
|
||||
<div className="space-y-4 text-sm text-primary-800 dark:text-primary-300">
|
||||
<p>
|
||||
API credentials allow you to programmatically access
|
||||
PatchMon data using Basic Authentication.
|
||||
</p>
|
||||
<div>
|
||||
<p className="font-semibold mb-2">
|
||||
Example cURL Request:
|
||||
</p>
|
||||
<div className="bg-primary-100 dark:bg-primary-900/40 p-3 rounded border border-primary-200 dark:border-primary-700 font-mono text-xs overflow-x-auto">
|
||||
curl -u "YOUR_API_KEY:YOUR_API_SECRET" \<br />
|
||||
{server_url}/api/v1/api/hosts
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold mb-2">
|
||||
Query Hosts by Group:
|
||||
</p>
|
||||
<div className="bg-primary-100 dark:bg-primary-900/40 p-3 rounded border border-primary-200 dark:border-primary-700 font-mono text-xs overflow-x-auto">
|
||||
curl -u "YOUR_API_KEY:YOUR_API_SECRET" \<br />
|
||||
"{server_url}
|
||||
/api/v1/api/hosts?hostgroup=Production,Development"
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs">
|
||||
<strong>💡 Tip:</strong> You can filter by host group
|
||||
names or UUIDs. Multiple groups can be specified as a
|
||||
comma-separated list.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Docker Tab */}
|
||||
{activeTab === "docker" && (
|
||||
<div className="space-y-6">
|
||||
@@ -1206,9 +885,7 @@ const Integrations = () => {
|
||||
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
|
||||
{activeTab === "gethomepage"
|
||||
? "Create GetHomepage API Key"
|
||||
: activeTab === "api"
|
||||
? "Create API Credential"
|
||||
: "Create Auto-Enrollment Token"}
|
||||
: "Create Auto-Enrollment Token"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1234,9 +911,7 @@ const Integrations = () => {
|
||||
placeholder={
|
||||
activeTab === "gethomepage"
|
||||
? "e.g., GetHomepage Widget"
|
||||
: activeTab === "api"
|
||||
? "e.g., Ansible Inventory"
|
||||
: "e.g., Proxmox Production"
|
||||
: "e.g., Proxmox Production"
|
||||
}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
@@ -1295,56 +970,6 @@ const Integrations = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === "api" && (
|
||||
<div className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Scopes *
|
||||
</span>
|
||||
<div className="border border-secondary-300 dark:border-secondary-600 rounded-md p-4 bg-secondary-50 dark:bg-secondary-900">
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-semibold text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Host Permissions
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{["get", "put", "patch", "update", "delete"].map(
|
||||
(action) => (
|
||||
<label
|
||||
key={action}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
form_data.scopes?.host?.includes(action) ||
|
||||
false
|
||||
}
|
||||
onChange={() =>
|
||||
toggle_scope_action("host", action)
|
||||
}
|
||||
className="rounded border-secondary-300 dark:border-secondary-600 text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-400"
|
||||
/>
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300 uppercase">
|
||||
{action}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
{action === "get" && "- Read host data"}
|
||||
{action === "put" && "- Replace host data"}
|
||||
{action === "patch" && "- Update host data"}
|
||||
{action === "update" && "- Modify host data"}
|
||||
{action === "delete" && "- Delete hosts"}
|
||||
</span>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Select the permissions this API credential should have
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Allowed IP Addresses (Optional)
|
||||
@@ -1413,9 +1038,7 @@ const Integrations = () => {
|
||||
<h2 className="text-lg font-bold text-secondary-900 dark:text-white">
|
||||
{activeTab === "gethomepage"
|
||||
? "API Key Created Successfully"
|
||||
: activeTab === "api"
|
||||
? "API Credential Created Successfully"
|
||||
: "Token Created Successfully"}
|
||||
: "Token Created Successfully"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
@@ -1538,103 +1161,6 @@ const Integrations = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === "api" && new_token.scopes && (
|
||||
<div className="mt-4">
|
||||
<div className="block text-xs font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Granted Scopes
|
||||
</div>
|
||||
<div className="bg-secondary-50 dark:bg-secondary-900 border border-secondary-300 dark:border-secondary-600 rounded-md p-3">
|
||||
{Object.entries(new_token.scopes).map(
|
||||
([resource, actions]) => (
|
||||
<div key={resource} className="text-sm">
|
||||
<span className="font-semibold text-secondary-800 dark:text-secondary-200 capitalize">
|
||||
{resource}:
|
||||
</span>{" "}
|
||||
<span className="text-secondary-600 dark:text-secondary-400">
|
||||
{Array.isArray(actions)
|
||||
? actions.join(", ").toUpperCase()
|
||||
: actions}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "api" && (
|
||||
<div className="mt-6">
|
||||
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Usage Examples
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-2">
|
||||
Basic cURL request:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`curl -u "${new_token.token_key}:${new_token.token_secret}" ${server_url}/api/v1/api/hosts`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-xs border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(
|
||||
`curl -u "${new_token.token_key}:${new_token.token_secret}" ${server_url}/api/v1/api/hosts`,
|
||||
"api-curl-basic",
|
||||
)
|
||||
}
|
||||
className="btn-primary p-2"
|
||||
title="Copy cURL command"
|
||||
>
|
||||
{copy_success["api-curl-basic"] ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-secondary-600 dark:text-secondary-400 mb-2">
|
||||
Filter by host group:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={`curl -u "${new_token.token_key}:${new_token.token_secret}" "${server_url}/api/v1/api/hosts?hostgroup=Production"`}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-xs border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copy_to_clipboard(
|
||||
`curl -u "${new_token.token_key}:${new_token.token_secret}" "${server_url}/api/v1/api/hosts?hostgroup=Production"`,
|
||||
"api-curl-filter",
|
||||
)
|
||||
}
|
||||
className="btn-primary p-2"
|
||||
title="Copy cURL command"
|
||||
>
|
||||
{copy_success["api-curl-filter"] ? (
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-3">
|
||||
💡 Replace "Production" with your host group name or UUID
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "proxmox" && (
|
||||
<div className="mt-6">
|
||||
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
@@ -1845,154 +1371,6 @@ const Integrations = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit API Credential Modal */}
|
||||
{show_edit_modal && edit_token && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
|
||||
Edit API Credential
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setEditToken(null);
|
||||
}}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-200"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={update_token} className="space-y-4">
|
||||
<div className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Token Name
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form_data.token_name}
|
||||
readOnly
|
||||
disabled
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-100 dark:bg-secondary-900 text-secondary-500 dark:text-secondary-400"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Token name cannot be changed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{edit_token?.metadata?.integration_type === "api" && (
|
||||
<div className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Scopes
|
||||
</span>
|
||||
<div className="border border-secondary-300 dark:border-secondary-600 rounded-md p-4 bg-secondary-50 dark:bg-secondary-900">
|
||||
<div className="mb-3">
|
||||
<p className="text-xs font-semibold text-secondary-700 dark:text-secondary-300 mb-2">
|
||||
Host Permissions
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{["get", "put", "patch", "update", "delete"].map(
|
||||
(action) => (
|
||||
<label
|
||||
key={action}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
form_data.scopes?.host?.includes(action) ||
|
||||
false
|
||||
}
|
||||
onChange={() =>
|
||||
toggle_scope_action("host", action)
|
||||
}
|
||||
className="rounded border-secondary-300 dark:border-secondary-600 text-primary-600 focus:ring-primary-500 dark:focus:ring-primary-400"
|
||||
/>
|
||||
<span className="text-sm text-secondary-700 dark:text-secondary-300 uppercase">
|
||||
{action}
|
||||
</span>
|
||||
<span className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
{action === "get" && "- Read host data"}
|
||||
{action === "put" && "- Replace host data"}
|
||||
{action === "patch" && "- Update host data"}
|
||||
{action === "update" && "- Modify host data"}
|
||||
{action === "delete" && "- Delete hosts"}
|
||||
</span>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Update the permissions for this API credential
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Allowed IP Addresses (Optional)
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form_data.allowed_ip_ranges}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...form_data,
|
||||
allowed_ip_ranges: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., 192.168.1.100, 10.0.0.50"
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Comma-separated list of IP addresses allowed to use this
|
||||
token
|
||||
</p>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Expiration Date (Optional)
|
||||
</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={form_data.expires_at}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...form_data, expires_at: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 btn-primary py-2 px-4 rounded-md"
|
||||
>
|
||||
Update Credential
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setEditToken(null);
|
||||
}}
|
||||
className="flex-1 bg-secondary-100 dark:bg-secondary-700 text-secondary-700 dark:text-secondary-300 py-2 px-4 rounded-md hover:bg-secondary-200 dark:hover:bg-secondary-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
171
package-lock.json
generated
171
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "patchmon",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "patchmon",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"license": "AGPL-3.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
@@ -23,14 +23,14 @@
|
||||
},
|
||||
"backend": {
|
||||
"name": "patchmon-backend",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@bull-board/api": "^6.13.1",
|
||||
"@bull-board/express": "^6.13.1",
|
||||
"@prisma/client": "^6.1.0",
|
||||
"axios": "^1.7.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bcryptjs": "^3.0.0",
|
||||
"bullmq": "^5.61.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
@@ -49,7 +49,7 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"nodemon": "^3.1.9",
|
||||
"prisma": "^6.1.0"
|
||||
},
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "patchmon-frontend",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -134,6 +134,7 @@
|
||||
"version": "7.28.4",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
@@ -547,6 +548,7 @@
|
||||
"node_modules/@bull-board/ui": {
|
||||
"version": "6.13.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@bull-board/api": "6.13.1"
|
||||
}
|
||||
@@ -580,6 +582,7 @@
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -988,9 +991,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
|
||||
"integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==",
|
||||
"deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcryptjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
@@ -1020,6 +1029,7 @@
|
||||
"version": "18.3.24",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -1183,8 +1193,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "2.4.3",
|
||||
"license": "MIT"
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
@@ -1267,6 +1282,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
@@ -1456,6 +1472,7 @@
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.0",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
@@ -2030,6 +2047,7 @@
|
||||
"node_modules/express": {
|
||||
"version": "4.21.2",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
@@ -2795,6 +2813,76 @@
|
||||
"lefthook-windows-x64": "1.13.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lefthook-darwin-arm64": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/lefthook-darwin-arm64/-/lefthook-darwin-arm64-1.13.5.tgz",
|
||||
"integrity": "sha512-BYt5CnAOXasVCS6i+A4ljUo9xru/B5uMFD6EWHhs3R26jGF7mBSDxM3ErzXTUaJRTP0kQI/XBmgqBryBqoqZOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/lefthook-darwin-x64": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/lefthook-darwin-x64/-/lefthook-darwin-x64-1.13.5.tgz",
|
||||
"integrity": "sha512-ZDtLBzvI5e26C/RZ4irOHpELTd22x9lDTgF2+eCYcnrBWOkB7800V8tuAvBybsLGvg6JwKjFxn+NTRNZnCC2hw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/lefthook-freebsd-arm64": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/lefthook-freebsd-arm64/-/lefthook-freebsd-arm64-1.13.5.tgz",
|
||||
"integrity": "sha512-uQ/kQZSSedw74aGCpsfOPN4yVt3klg8grOP6gHQOCRUMv5oK/Lj3pe1PylpTuuhxWORWRzkauPMot26J0OZZdA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/lefthook-freebsd-x64": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/lefthook-freebsd-x64/-/lefthook-freebsd-x64-1.13.5.tgz",
|
||||
"integrity": "sha512-6czek8XagVrI7ExURawkfrfX40Qjc/wktc8bLq/iXfRlmdvKDMrx2FrA82mDfEVCAEz+tTvkteK1TfR3icYF3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/lefthook-linux-arm64": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/lefthook-linux-arm64/-/lefthook-linux-arm64-1.13.5.tgz",
|
||||
"integrity": "sha512-MjWtiuW1br+rpTtgG1KGV53mSGtL5MWQwgafYzrFleJ89fKb86F4TD/4mVNzk5thmZ+HVPZw9bRZGUHFBnNJWg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/lefthook-linux-x64": {
|
||||
"version": "1.13.5",
|
||||
"cpu": [
|
||||
@@ -2807,6 +2895,62 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/lefthook-openbsd-arm64": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/lefthook-openbsd-arm64/-/lefthook-openbsd-arm64-1.13.5.tgz",
|
||||
"integrity": "sha512-lYXrWf0/hBrwtG8ceaHq886bcqRKh3Lfv+jZJs+ykMLB6L/kaqk8tA4V2NHWydQ5h56o45ugs/580nMz36ZdRg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/lefthook-openbsd-x64": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/lefthook-openbsd-x64/-/lefthook-openbsd-x64-1.13.5.tgz",
|
||||
"integrity": "sha512-Ba1JrsRbfan4WKd8Q7gUhTxCUuppXzirDObd3JxpLRSLxA47yxhjMv7KByDunRDTvzTgsXoykZI6mPupkc1JiQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/lefthook-windows-arm64": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/lefthook-windows-arm64/-/lefthook-windows-arm64-1.13.5.tgz",
|
||||
"integrity": "sha512-Y/CpmEIb0hlFe+kTT/efWgX6+/gUTp5NItTF+gmUrY1/G/bTLIxdIRS7WpodVM0MEN24sOrQVTSi9DN9FvGoGg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/lefthook-windows-x64": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/lefthook-windows-x64/-/lefthook-windows-x64-1.13.5.tgz",
|
||||
"integrity": "sha512-WJBqGNBlFJnunRwy12QyaDHdGULtostPqpYSZSS4boFJDY0lP5qtz9lAGmJ49aA5GQ19jrnDjGLwVPFiwIqksQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"dev": true,
|
||||
@@ -3419,6 +3563,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3548,6 +3693,7 @@
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.16.2",
|
||||
"@prisma/engines": "6.16.2"
|
||||
@@ -3737,6 +3883,7 @@
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -3755,6 +3902,7 @@
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -4472,6 +4620,7 @@
|
||||
"version": "4.0.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4624,6 +4773,7 @@
|
||||
"version": "7.1.7",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -4713,6 +4863,7 @@
|
||||
"version": "4.0.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user