mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-11 17:36:04 +00:00
Compare commits
4 Commits
renovate/b
...
feature/ap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f6466c80a | ||
|
|
d1069a8bd0 | ||
|
|
bedcd1ac73 | ||
|
|
f0b028cb77 |
@@ -1,7 +1,32 @@
|
|||||||
#!/bin/bash
|
#!/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
|
||||||
|
|
||||||
# PatchMon Agent Installation Script
|
# 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
|
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/install -H "X-API-ID: {API_ID}" -H "X-API-KEY: {API_KEY}" | sh
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
@@ -36,7 +61,7 @@ warning() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Check if running as root
|
# 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)"
|
error "This script must be run as root (use sudo)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -45,8 +70,8 @@ verify_datetime() {
|
|||||||
info "🕐 Verifying system datetime and timezone..."
|
info "🕐 Verifying system datetime and timezone..."
|
||||||
|
|
||||||
# Get current system time
|
# Get current system time
|
||||||
local system_time=$(date)
|
system_time=$(date)
|
||||||
local timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
|
timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
|
||||||
|
|
||||||
# Display current datetime info
|
# Display current datetime info
|
||||||
echo ""
|
echo ""
|
||||||
@@ -56,14 +81,17 @@ verify_datetime() {
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Check if we can read from stdin (interactive terminal)
|
# Check if we can read from stdin (interactive terminal)
|
||||||
if [[ -t 0 ]]; then
|
if [ -t 0 ]; then
|
||||||
# Interactive terminal - ask user
|
# Interactive terminal - ask user
|
||||||
read -p "Does this date/time look correct to you? (y/N): " -r response
|
printf "Does this date/time look correct to you? (y/N): "
|
||||||
if [[ "$response" =~ ^[Yy]$ ]]; then
|
read -r response
|
||||||
success "✅ Date/time verification passed"
|
case "$response" in
|
||||||
echo ""
|
[Yy]*)
|
||||||
return 0
|
success "✅ Date/time verification passed"
|
||||||
else
|
echo ""
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${RED}❌ Date/time verification failed${NC}"
|
echo -e "${RED}❌ Date/time verification failed${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -72,9 +100,10 @@ verify_datetime() {
|
|||||||
echo " sudo timedatectl set-timezone 'America/New_York' # or your timezone"
|
echo " sudo timedatectl set-timezone 'America/New_York' # or your timezone"
|
||||||
echo " sudo timedatectl list-timezones # to see available timezones"
|
echo " sudo timedatectl list-timezones # to see available timezones"
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${BLUE}ℹ️ After fixing the date/time, re-run this installation script.${NC}"
|
echo -e "${BLUE}ℹ️ After fixing the date/time, re-run this installation script.${NC}"
|
||||||
error "Installation cancelled - please fix date/time and re-run"
|
error "Installation cancelled - please fix date/time and re-run"
|
||||||
fi
|
;;
|
||||||
|
esac
|
||||||
else
|
else
|
||||||
# Non-interactive (piped from curl) - show warning and continue
|
# Non-interactive (piped from curl) - show warning and continue
|
||||||
echo -e "${YELLOW}⚠️ Non-interactive installation detected${NC}"
|
echo -e "${YELLOW}⚠️ Non-interactive installation detected${NC}"
|
||||||
@@ -121,9 +150,9 @@ cleanup_old_files
|
|||||||
# Generate or retrieve machine ID
|
# Generate or retrieve machine ID
|
||||||
get_machine_id() {
|
get_machine_id() {
|
||||||
# Try multiple sources for machine ID
|
# Try multiple sources for machine ID
|
||||||
if [[ -f /etc/machine-id ]]; then
|
if [ -f /etc/machine-id ]; then
|
||||||
cat /etc/machine-id
|
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
|
cat /var/lib/dbus/machine-id
|
||||||
else
|
else
|
||||||
# Fallback: generate from hardware info (less ideal but works)
|
# Fallback: generate from hardware info (less ideal but works)
|
||||||
@@ -132,12 +161,12 @@ get_machine_id() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments from environment (passed via HTTP headers)
|
# 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."
|
error "Missing required parameters. This script should be called via the PatchMon web interface."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Auto-detect architecture if not explicitly set
|
# Auto-detect architecture if not explicitly set
|
||||||
if [[ -z "$ARCHITECTURE" ]]; then
|
if [ -z "$ARCHITECTURE" ]; then
|
||||||
arch_raw=$(uname -m 2>/dev/null || echo "unknown")
|
arch_raw=$(uname -m 2>/dev/null || echo "unknown")
|
||||||
|
|
||||||
# Map architecture to supported values
|
# Map architecture to supported values
|
||||||
@@ -162,13 +191,16 @@ if [[ -z "$ARCHITECTURE" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Validate architecture
|
# 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"
|
error "Invalid architecture '$ARCHITECTURE'. Must be one of: amd64, 386, arm64, arm"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if --force flag is set (for bypassing broken packages)
|
# Check if --force flag is set (for bypassing broken packages)
|
||||||
FORCE_INSTALL="${FORCE_INSTALL:-false}"
|
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"
|
FORCE_INSTALL="true"
|
||||||
warning "⚠️ Force mode enabled - will bypass broken packages"
|
warning "⚠️ Force mode enabled - will bypass broken packages"
|
||||||
fi
|
fi
|
||||||
@@ -224,7 +256,7 @@ install_apt_packages() {
|
|||||||
# Build apt-get command based on force mode
|
# Build apt-get command based on force mode
|
||||||
local apt_cmd="apt-get install ${missing_packages[*]} -y"
|
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..."
|
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
|
fi
|
||||||
@@ -240,7 +272,7 @@ install_apt_packages() {
|
|||||||
local all_ok=true
|
local all_ok=true
|
||||||
for pkg in "${packages[@]}"; do
|
for pkg in "${packages[@]}"; do
|
||||||
if ! command_exists "$pkg"; then
|
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."
|
error "Critical dependency '$pkg' is not available even with --force. Please install manually."
|
||||||
else
|
else
|
||||||
error "Critical dependency '$pkg' is not available. Try again with --force flag or install manually: apt-get install $pkg"
|
error "Critical dependency '$pkg' is not available. Try again with --force flag or install manually: apt-get install $pkg"
|
||||||
@@ -279,7 +311,7 @@ install_yum_dnf_packages() {
|
|||||||
|
|
||||||
info "Need to install: ${missing_packages[*]}"
|
info "Need to install: ${missing_packages[*]}"
|
||||||
|
|
||||||
if [[ "$pkg_manager" == "yum" ]]; then
|
if [ "$pkg_manager" = "yum" ]; then
|
||||||
yum install -y "${missing_packages[@]}"
|
yum install -y "${missing_packages[@]}"
|
||||||
else
|
else
|
||||||
dnf install -y "${missing_packages[@]}"
|
dnf install -y "${missing_packages[@]}"
|
||||||
@@ -365,7 +397,7 @@ install_apk_packages() {
|
|||||||
local all_ok=true
|
local all_ok=true
|
||||||
for pkg in "${packages[@]}"; do
|
for pkg in "${packages[@]}"; do
|
||||||
if ! command_exists "$pkg"; then
|
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."
|
error "Critical dependency '$pkg' is not available even with --force. Please install manually."
|
||||||
else
|
else
|
||||||
error "Critical dependency '$pkg' is not available. Try again with --force flag or install manually: apk add $pkg"
|
error "Critical dependency '$pkg' is not available. Try again with --force flag or install manually: apk add $pkg"
|
||||||
@@ -391,7 +423,7 @@ if command -v apt-get >/dev/null 2>&1; then
|
|||||||
|
|
||||||
# Check for broken packages
|
# Check for broken packages
|
||||||
if dpkg -l | grep -q "^iH\|^iF" 2>/dev/null; then
|
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"
|
warning "Detected broken packages on system - force mode will work around them"
|
||||||
else
|
else
|
||||||
warning "⚠️ Broken packages detected on system"
|
warning "⚠️ Broken packages detected on system"
|
||||||
@@ -446,7 +478,7 @@ echo ""
|
|||||||
info "📁 Setting up configuration directory..."
|
info "📁 Setting up configuration directory..."
|
||||||
|
|
||||||
# Check if configuration directory already exists
|
# 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 "⚠️ Configuration directory already exists at /etc/patchmon"
|
||||||
warning "⚠️ Preserving existing configuration files"
|
warning "⚠️ Preserving existing configuration files"
|
||||||
|
|
||||||
@@ -463,8 +495,8 @@ fi
|
|||||||
# Check if agent is already configured and working (before we overwrite anything)
|
# 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 /etc/patchmon/config.yml ] && [ -f /etc/patchmon/credentials.yml ]; then
|
||||||
if [[ -f /usr/local/bin/patchmon-agent ]]; then
|
if [ -f /usr/local/bin/patchmon-agent ]; then
|
||||||
info "📋 Found existing agent configuration"
|
info "📋 Found existing agent configuration"
|
||||||
info "🧪 Testing existing configuration with ping..."
|
info "🧪 Testing existing configuration with ping..."
|
||||||
|
|
||||||
@@ -495,7 +527,7 @@ fi
|
|||||||
info "🔐 Creating configuration files..."
|
info "🔐 Creating configuration files..."
|
||||||
|
|
||||||
# Check if config file already exists
|
# 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 "⚠️ Config file already exists at /etc/patchmon/config.yml"
|
||||||
warning "⚠️ Moving existing file out of the way for fresh installation"
|
warning "⚠️ Moving existing file out of the way for fresh installation"
|
||||||
|
|
||||||
@@ -508,7 +540,7 @@ if [[ -f "/etc/patchmon/config.yml" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if credentials file already exists
|
# 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 "⚠️ Credentials file already exists at /etc/patchmon/credentials.yml"
|
||||||
warning "⚠️ Moving existing file out of the way for fresh installation"
|
warning "⚠️ Moving existing file out of the way for fresh installation"
|
||||||
|
|
||||||
@@ -521,7 +553,7 @@ if [[ -f "/etc/patchmon/credentials.yml" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Clean up old credentials file if it exists (from previous installations)
|
# 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..."
|
warning "⚠️ Found old credentials file, removing it..."
|
||||||
rm -f /etc/patchmon/credentials
|
rm -f /etc/patchmon/credentials
|
||||||
info "📋 Removed old credentials file"
|
info "📋 Removed old credentials file"
|
||||||
@@ -557,7 +589,7 @@ info "📥 Downloading PatchMon agent binary..."
|
|||||||
BINARY_NAME="patchmon-agent-linux-${ARCHITECTURE}"
|
BINARY_NAME="patchmon-agent-linux-${ARCHITECTURE}"
|
||||||
|
|
||||||
# Check if agent binary already exists
|
# 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 "⚠️ Agent binary already exists at /usr/local/bin/patchmon-agent"
|
||||||
warning "⚠️ Moving existing file out of the way for fresh installation"
|
warning "⚠️ Moving existing file out of the way for fresh installation"
|
||||||
|
|
||||||
@@ -570,7 +602,7 @@ if [[ -f "/usr/local/bin/patchmon-agent" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Clean up old shell script if it exists (from previous installations)
|
# 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..."
|
warning "⚠️ Found old shell script agent, removing it..."
|
||||||
rm -f /usr/local/bin/patchmon-agent.sh
|
rm -f /usr/local/bin/patchmon-agent.sh
|
||||||
info "📋 Removed old shell script agent"
|
info "📋 Removed old shell script agent"
|
||||||
@@ -596,7 +628,7 @@ info "📁 Setting up log directory..."
|
|||||||
mkdir -p /etc/patchmon/logs
|
mkdir -p /etc/patchmon/logs
|
||||||
|
|
||||||
# Handle existing log files
|
# 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 "⚠️ Existing log file found at /etc/patchmon/logs/patchmon-agent.log"
|
||||||
warning "⚠️ Rotating log file for fresh start"
|
warning "⚠️ Rotating log file for fresh start"
|
||||||
|
|
||||||
@@ -613,23 +645,26 @@ else
|
|||||||
error "❌ Failed to validate API credentials or reach server"
|
error "❌ Failed to validate API credentials or reach server"
|
||||||
fi
|
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)
|
# Note: The service will automatically send an initial report on startup (see serve.go)
|
||||||
info "🔧 Setting up systemd service..."
|
# Detect init system and create appropriate service
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
# Stop and disable existing service if it exists
|
# Systemd is available
|
||||||
if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then
|
info "🔧 Setting up systemd service..."
|
||||||
warning "⚠️ Stopping existing PatchMon agent service..."
|
|
||||||
systemctl stop patchmon-agent.service
|
# Stop and disable existing service if it exists
|
||||||
fi
|
if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then
|
||||||
|
warning "⚠️ Stopping existing PatchMon agent service..."
|
||||||
if systemctl is-enabled --quiet patchmon-agent.service 2>/dev/null; then
|
systemctl stop patchmon-agent.service
|
||||||
warning "⚠️ Disabling existing PatchMon agent service..."
|
fi
|
||||||
systemctl disable patchmon-agent.service
|
|
||||||
fi
|
if systemctl is-enabled --quiet patchmon-agent.service 2>/dev/null; then
|
||||||
|
warning "⚠️ Disabling existing PatchMon agent service..."
|
||||||
# Create systemd service file
|
systemctl disable patchmon-agent.service
|
||||||
cat > /etc/systemd/system/patchmon-agent.service << EOF
|
fi
|
||||||
|
|
||||||
|
# Create systemd service file
|
||||||
|
cat > /etc/systemd/system/patchmon-agent.service << EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=PatchMon Agent Service
|
Description=PatchMon Agent Service
|
||||||
After=network.target
|
After=network.target
|
||||||
@@ -651,25 +686,105 @@ SyslogIdentifier=patchmon-agent
|
|||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
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)
|
name="patchmon-agent"
|
||||||
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
|
description="PatchMon Agent Service"
|
||||||
warning "⚠️ Found old crontab entries, removing them..."
|
command="/usr/local/bin/patchmon-agent"
|
||||||
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
|
command_args="serve"
|
||||||
info "📋 Removed old crontab entries"
|
command_user="root"
|
||||||
fi
|
pidfile="/var/run/patchmon-agent.pid"
|
||||||
|
command_background="yes"
|
||||||
|
working_dir="/etc/patchmon"
|
||||||
|
|
||||||
# Reload systemd and enable/start the service
|
depend() {
|
||||||
systemctl daemon-reload
|
need net
|
||||||
systemctl enable patchmon-agent.service
|
after net
|
||||||
systemctl start patchmon-agent.service
|
}
|
||||||
|
EOF
|
||||||
# Check if service started successfully
|
|
||||||
if systemctl is-active --quiet patchmon-agent.service; then
|
chmod +x /etc/init.d/patchmon-agent
|
||||||
success "✅ PatchMon Agent service started successfully"
|
|
||||||
info "🔗 WebSocket connection established"
|
# 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
|
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
|
fi
|
||||||
|
|
||||||
# Installation complete
|
# Installation complete
|
||||||
@@ -680,14 +795,20 @@ echo " • Configuration directory: /etc/patchmon"
|
|||||||
echo " • Agent binary installed: /usr/local/bin/patchmon-agent"
|
echo " • Agent binary installed: /usr/local/bin/patchmon-agent"
|
||||||
echo " • Architecture: $ARCHITECTURE"
|
echo " • Architecture: $ARCHITECTURE"
|
||||||
echo " • Dependencies installed: jq, curl, bc"
|
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 " • API credentials configured and tested"
|
||||||
echo " • WebSocket connection established"
|
echo " • WebSocket connection established"
|
||||||
echo " • Logs directory: /etc/patchmon/logs"
|
echo " • Logs directory: /etc/patchmon/logs"
|
||||||
|
|
||||||
# Check for moved files and show them
|
# 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)
|
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 ""
|
||||||
echo -e "${YELLOW}📋 Files Moved for Fresh Installation:${NC}"
|
echo -e "${YELLOW}📋 Files Moved for Fresh Installation:${NC}"
|
||||||
echo "$MOVED_FILES" | while read -r moved_file; do
|
echo "$MOVED_FILES" | while read -r moved_file; do
|
||||||
@@ -702,8 +823,17 @@ echo -e "${BLUE}🔧 Management Commands:${NC}"
|
|||||||
echo " • Test connection: /usr/local/bin/patchmon-agent ping"
|
echo " • Test connection: /usr/local/bin/patchmon-agent ping"
|
||||||
echo " • Manual report: /usr/local/bin/patchmon-agent report"
|
echo " • Manual report: /usr/local/bin/patchmon-agent report"
|
||||||
echo " • Check status: /usr/local/bin/patchmon-agent diagnostics"
|
echo " • Check status: /usr/local/bin/patchmon-agent diagnostics"
|
||||||
echo " • Service status: systemctl status patchmon-agent"
|
if [ "$SERVICE_TYPE" = "systemd" ]; then
|
||||||
echo " • Service logs: journalctl -u patchmon-agent -f"
|
echo " • Service status: systemctl status patchmon-agent"
|
||||||
echo " • Restart service: systemctl restart 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 ""
|
echo ""
|
||||||
success "✅ Your system is now being monitored by PatchMon!"
|
success "✅ Your system is now being monitored by PatchMon!"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "auto_enrollment_tokens" ADD COLUMN "scopes" JSONB;
|
||||||
|
|
||||||
@@ -288,6 +288,7 @@ model auto_enrollment_tokens {
|
|||||||
last_used_at DateTime?
|
last_used_at DateTime?
|
||||||
expires_at DateTime?
|
expires_at DateTime?
|
||||||
metadata Json?
|
metadata Json?
|
||||||
|
scopes Json?
|
||||||
users users? @relation(fields: [created_by_user_id], references: [id], onDelete: SetNull)
|
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)
|
host_groups host_groups? @relation(fields: [default_host_group_id], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
|||||||
113
backend/src/middleware/apiAuth.js
Normal file
113
backend/src/middleware/apiAuth.js
Normal 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 };
|
||||||
76
backend/src/middleware/apiScope.js
Normal file
76
backend/src/middleware/apiScope.js
Normal 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 };
|
||||||
143
backend/src/routes/apiHostsRoutes.js
Normal file
143
backend/src/routes/apiHostsRoutes.js
Normal 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;
|
||||||
@@ -125,6 +125,10 @@ router.post(
|
|||||||
.optional({ nullable: true, checkFalsy: true })
|
.optional({ nullable: true, checkFalsy: true })
|
||||||
.isISO8601()
|
.isISO8601()
|
||||||
.withMessage("Invalid date format"),
|
.withMessage("Invalid date format"),
|
||||||
|
body("scopes")
|
||||||
|
.optional()
|
||||||
|
.isObject()
|
||||||
|
.withMessage("Scopes must be an object"),
|
||||||
],
|
],
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -140,6 +144,7 @@ router.post(
|
|||||||
default_host_group_id,
|
default_host_group_id,
|
||||||
expires_at,
|
expires_at,
|
||||||
metadata = {},
|
metadata = {},
|
||||||
|
scopes,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Validate host group if provided
|
// 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 { token_key, token_secret } = generate_auto_enrollment_token();
|
||||||
const hashed_secret = await bcrypt.hash(token_secret, 10);
|
const hashed_secret = await bcrypt.hash(token_secret, 10);
|
||||||
|
|
||||||
@@ -168,6 +199,7 @@ router.post(
|
|||||||
default_host_group_id: default_host_group_id || null,
|
default_host_group_id: default_host_group_id || null,
|
||||||
expires_at: expires_at ? new Date(expires_at) : null,
|
expires_at: expires_at ? new Date(expires_at) : null,
|
||||||
metadata: { integration_type: "proxmox-lxc", ...metadata },
|
metadata: { integration_type: "proxmox-lxc", ...metadata },
|
||||||
|
scopes: metadata.integration_type === "api" ? scopes || null : null,
|
||||||
updated_at: new Date(),
|
updated_at: new Date(),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -201,6 +233,7 @@ router.post(
|
|||||||
default_host_group: token.host_groups,
|
default_host_group: token.host_groups,
|
||||||
created_by: token.users,
|
created_by: token.users,
|
||||||
expires_at: token.expires_at,
|
expires_at: token.expires_at,
|
||||||
|
scopes: token.scopes,
|
||||||
},
|
},
|
||||||
warning: "⚠️ Save the token_secret now - it cannot be retrieved later!",
|
warning: "⚠️ Save the token_secret now - it cannot be retrieved later!",
|
||||||
});
|
});
|
||||||
@@ -232,6 +265,7 @@ router.get(
|
|||||||
created_at: true,
|
created_at: true,
|
||||||
default_host_group_id: true,
|
default_host_group_id: true,
|
||||||
metadata: true,
|
metadata: true,
|
||||||
|
scopes: true,
|
||||||
host_groups: {
|
host_groups: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -314,6 +348,10 @@ router.patch(
|
|||||||
body("max_hosts_per_day").optional().isInt({ min: 1, max: 1000 }),
|
body("max_hosts_per_day").optional().isInt({ min: 1, max: 1000 }),
|
||||||
body("allowed_ip_ranges").optional().isArray(),
|
body("allowed_ip_ranges").optional().isArray(),
|
||||||
body("expires_at").optional().isISO8601(),
|
body("expires_at").optional().isISO8601(),
|
||||||
|
body("scopes")
|
||||||
|
.optional()
|
||||||
|
.isObject()
|
||||||
|
.withMessage("Scopes must be an object"),
|
||||||
],
|
],
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -323,6 +361,16 @@ router.patch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { tokenId } = req.params;
|
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() };
|
const update_data = { updated_at: new Date() };
|
||||||
|
|
||||||
if (req.body.is_active !== undefined)
|
if (req.body.is_active !== undefined)
|
||||||
@@ -334,6 +382,41 @@ router.patch(
|
|||||||
if (req.body.expires_at !== undefined)
|
if (req.body.expires_at !== undefined)
|
||||||
update_data.expires_at = new Date(req.body.expires_at);
|
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({
|
const token = await prisma.auto_enrollment_tokens.update({
|
||||||
where: { id: tokenId },
|
where: { id: tokenId },
|
||||||
data: update_data,
|
data: update_data,
|
||||||
|
|||||||
@@ -1,113 +1,12 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { getPrismaClient } = require("../config/prisma");
|
const { getPrismaClient } = require("../config/prisma");
|
||||||
const bcrypt = require("bcryptjs");
|
const { authenticateApiToken } = require("../middleware/apiAuth");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const prisma = getPrismaClient();
|
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
|
// Get homepage widget statistics
|
||||||
router.get("/stats", authenticateApiKey, async (_req, res) => {
|
router.get("/stats", authenticateApiToken("gethomepage"), async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
// Get total hosts count
|
// Get total hosts count
|
||||||
const totalHosts = await prisma.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
|
// Health check endpoint for the API
|
||||||
router.get("/health", authenticateApiKey, async (req, res) => {
|
router.get("/health", authenticateApiToken("gethomepage"), async (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ const wsRoutes = require("./routes/wsRoutes");
|
|||||||
const agentVersionRoutes = require("./routes/agentVersionRoutes");
|
const agentVersionRoutes = require("./routes/agentVersionRoutes");
|
||||||
const metricsRoutes = require("./routes/metricsRoutes");
|
const metricsRoutes = require("./routes/metricsRoutes");
|
||||||
const userPreferencesRoutes = require("./routes/userPreferencesRoutes");
|
const userPreferencesRoutes = require("./routes/userPreferencesRoutes");
|
||||||
|
const apiHostsRoutes = require("./routes/apiHostsRoutes");
|
||||||
const { initSettings } = require("./services/settingsService");
|
const { initSettings } = require("./services/settingsService");
|
||||||
const { queueManager } = require("./services/automation");
|
const { queueManager } = require("./services/automation");
|
||||||
const { authenticateToken, requireAdmin } = require("./middleware/auth");
|
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}/agent`, agentVersionRoutes);
|
||||||
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
|
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
|
||||||
app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes);
|
app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes);
|
||||||
|
app.use(`/api/${apiVersion}/api`, authLimiter, apiHostsRoutes);
|
||||||
|
|
||||||
// Bull Board - will be populated after queue manager initializes
|
// Bull Board - will be populated after queue manager initializes
|
||||||
let bullBoardRouter = null;
|
let bullBoardRouter = null;
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const Integrations = () => {
|
|||||||
const [host_groups, setHostGroups] = useState([]);
|
const [host_groups, setHostGroups] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [show_create_modal, setShowCreateModal] = useState(false);
|
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 [new_token, setNewToken] = useState(null);
|
||||||
const [show_secret, setShowSecret] = useState(false);
|
const [show_secret, setShowSecret] = useState(false);
|
||||||
const [server_url, setServerUrl] = useState("");
|
const [server_url, setServerUrl] = useState("");
|
||||||
@@ -40,6 +42,9 @@ const Integrations = () => {
|
|||||||
default_host_group_id: "",
|
default_host_group_id: "",
|
||||||
allowed_ip_ranges: "",
|
allowed_ip_ranges: "",
|
||||||
expires_at: "",
|
expires_at: "",
|
||||||
|
scopes: {
|
||||||
|
host: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [copy_success, setCopySuccess] = useState({});
|
const [copy_success, setCopySuccess] = useState({});
|
||||||
@@ -54,6 +59,25 @@ const Integrations = () => {
|
|||||||
setActiveTab(tabName);
|
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
|
// biome-ignore lint/correctness/useExhaustiveDependencies: Only run on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load_tokens();
|
load_tokens();
|
||||||
@@ -96,6 +120,14 @@ const Integrations = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
try {
|
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 = {
|
const data = {
|
||||||
token_name: form_data.token_name,
|
token_name: form_data.token_name,
|
||||||
max_hosts_per_day: Number.parseInt(form_data.max_hosts_per_day, 10),
|
max_hosts_per_day: Number.parseInt(form_data.max_hosts_per_day, 10),
|
||||||
@@ -103,8 +135,7 @@ const Integrations = () => {
|
|||||||
? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim())
|
? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim())
|
||||||
: [],
|
: [],
|
||||||
metadata: {
|
metadata: {
|
||||||
integration_type:
|
integration_type: integration_type,
|
||||||
activeTab === "gethomepage" ? "gethomepage" : "proxmox-lxc",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,6 +147,11 @@ const Integrations = () => {
|
|||||||
data.expires_at = form_data.expires_at;
|
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);
|
const response = await api.post("/auto-enrollment/tokens", data);
|
||||||
setNewToken(response.data.token);
|
setNewToken(response.data.token);
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
@@ -128,6 +164,9 @@ const Integrations = () => {
|
|||||||
default_host_group_id: "",
|
default_host_group_id: "",
|
||||||
allowed_ip_ranges: "",
|
allowed_ip_ranges: "",
|
||||||
expires_at: "",
|
expires_at: "",
|
||||||
|
scopes: {
|
||||||
|
host: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create token:", error);
|
console.error("Failed to create token:", error);
|
||||||
@@ -168,6 +207,69 @@ 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) => {
|
const copy_to_clipboard = async (text, key) => {
|
||||||
// Check if Clipboard API is available
|
// Check if Clipboard API is available
|
||||||
if (navigator.clipboard && window.isSecureContext) {
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
@@ -256,6 +358,17 @@ const Integrations = () => {
|
|||||||
>
|
>
|
||||||
GetHomepage
|
GetHomepage
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleTabChange("docker")}
|
onClick={() => handleTabChange("docker")}
|
||||||
@@ -736,6 +849,214 @@ const Integrations = () => {
|
|||||||
</div>
|
</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 */}
|
{/* Docker Tab */}
|
||||||
{activeTab === "docker" && (
|
{activeTab === "docker" && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -885,7 +1206,9 @@ const Integrations = () => {
|
|||||||
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
|
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
|
||||||
{activeTab === "gethomepage"
|
{activeTab === "gethomepage"
|
||||||
? "Create GetHomepage API Key"
|
? "Create GetHomepage API Key"
|
||||||
: "Create Auto-Enrollment Token"}
|
: activeTab === "api"
|
||||||
|
? "Create API Credential"
|
||||||
|
: "Create Auto-Enrollment Token"}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -911,7 +1234,9 @@ const Integrations = () => {
|
|||||||
placeholder={
|
placeholder={
|
||||||
activeTab === "gethomepage"
|
activeTab === "gethomepage"
|
||||||
? "e.g., GetHomepage Widget"
|
? "e.g., GetHomepage Widget"
|
||||||
: "e.g., Proxmox Production"
|
: activeTab === "api"
|
||||||
|
? "e.g., Ansible Inventory"
|
||||||
|
: "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"
|
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"
|
||||||
/>
|
/>
|
||||||
@@ -970,6 +1295,56 @@ 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">
|
<label className="block">
|
||||||
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||||
Allowed IP Addresses (Optional)
|
Allowed IP Addresses (Optional)
|
||||||
@@ -1038,7 +1413,9 @@ const Integrations = () => {
|
|||||||
<h2 className="text-lg font-bold text-secondary-900 dark:text-white">
|
<h2 className="text-lg font-bold text-secondary-900 dark:text-white">
|
||||||
{activeTab === "gethomepage"
|
{activeTab === "gethomepage"
|
||||||
? "API Key Created Successfully"
|
? "API Key Created Successfully"
|
||||||
: "Token Created Successfully"}
|
: activeTab === "api"
|
||||||
|
? "API Credential Created Successfully"
|
||||||
|
: "Token Created Successfully"}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -1161,6 +1538,103 @@ const Integrations = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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" && (
|
{activeTab === "proxmox" && (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
|
||||||
@@ -1371,6 +1845,154 @@ const Integrations = () => {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user