Merge pull request #301 from PatchMon/feature/api

Feature/api
This commit is contained in:
9 Technology Group LTD
2025-11-10 20:41:36 +00:00
committed by GitHub
9 changed files with 1250 additions and 181 deletions

View File

@@ -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
# 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
@@ -36,7 +61,7 @@ warning() {
}
# Check if running as root
if [[ $EUID -ne 0 ]]; then
if [ "$(id -u)" -ne 0 ]; then
error "This script must be run as root (use sudo)"
fi
@@ -45,8 +70,8 @@ verify_datetime() {
info "🕐 Verifying system datetime and timezone..."
# Get current system time
local system_time=$(date)
local timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
system_time=$(date)
timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
# Display current datetime info
echo ""
@@ -56,14 +81,17 @@ verify_datetime() {
echo ""
# Check if we can read from stdin (interactive terminal)
if [[ -t 0 ]]; then
if [ -t 0 ]; then
# Interactive terminal - ask user
read -p "Does this date/time look correct to you? (y/N): " -r response
if [[ "$response" =~ ^[Yy]$ ]]; then
success "✅ Date/time verification passed"
echo ""
return 0
else
printf "Does this date/time look correct to you? (y/N): "
read -r response
case "$response" in
[Yy]*)
success "✅ Date/time verification passed"
echo ""
return 0
;;
*)
echo ""
echo -e "${RED}❌ Date/time verification failed${NC}"
echo ""
@@ -72,9 +100,10 @@ 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"
fi
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
else
# Non-interactive (piped from curl) - show warning and continue
echo -e "${YELLOW}⚠️ Non-interactive installation detected${NC}"
@@ -121,9 +150,9 @@ cleanup_old_files
# Generate or retrieve machine ID
get_machine_id() {
# Try multiple sources for machine ID
if [[ -f /etc/machine-id ]]; then
if [ -f /etc/machine-id ]; then
cat /etc/machine-id
elif [[ -f /var/lib/dbus/machine-id ]]; then
elif [ -f /var/lib/dbus/machine-id ]; then
cat /var/lib/dbus/machine-id
else
# Fallback: generate from hardware info (less ideal but works)
@@ -132,12 +161,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
@@ -162,13 +191,16 @@ 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}"
if [[ "$*" == *"--force"* ]] || [[ "$FORCE_INSTALL" == "true" ]]; then
case "$*" in
*"--force"*) FORCE_INSTALL="true" ;;
esac
if [ "$FORCE_INSTALL" = "true" ]; then
FORCE_INSTALL="true"
warning "⚠️ Force mode enabled - will bypass broken packages"
fi
@@ -224,7 +256,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
@@ -240,7 +272,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"
@@ -279,7 +311,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[@]}"
@@ -365,7 +397,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"
@@ -391,7 +423,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"
@@ -446,7 +478,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"
@@ -463,8 +495,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..."
@@ -495,7 +527,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"
@@ -508,7 +540,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"
@@ -521,7 +553,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"
@@ -557,7 +589,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"
@@ -570,7 +602,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"
@@ -596,7 +628,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"
@@ -613,23 +645,26 @@ else
error "❌ Failed to validate API credentials or reach server"
fi
# Step 5: Setup systemd service for WebSocket connection
# Step 5: Setup service for WebSocket connection
# Note: The service will automatically send an initial report on startup (see serve.go)
info "🔧 Setting up systemd service..."
# Stop and disable existing service if it exists
if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then
warning "⚠️ Stopping existing PatchMon agent service..."
systemctl stop patchmon-agent.service
fi
if systemctl is-enabled --quiet patchmon-agent.service 2>/dev/null; then
warning "⚠️ Disabling existing PatchMon agent service..."
systemctl disable patchmon-agent.service
fi
# Create systemd service file
cat > /etc/systemd/system/patchmon-agent.service << EOF
# Detect init system and create appropriate service
if command -v systemctl >/dev/null 2>&1; then
# Systemd is available
info "🔧 Setting up systemd service..."
# Stop and disable existing service if it exists
if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then
warning "⚠️ Stopping existing PatchMon agent service..."
systemctl stop patchmon-agent.service
fi
if systemctl is-enabled --quiet patchmon-agent.service 2>/dev/null; then
warning "⚠️ Disabling existing PatchMon agent service..."
systemctl disable patchmon-agent.service
fi
# Create systemd service file
cat > /etc/systemd/system/patchmon-agent.service << EOF
[Unit]
Description=PatchMon Agent Service
After=network.target
@@ -651,25 +686,105 @@ SyslogIdentifier=patchmon-agent
[Install]
WantedBy=multi-user.target
EOF
# Clean up old crontab entries if they exist (from previous installations)
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
warning "⚠️ Found old crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
info "📋 Removed old crontab entries"
fi
# Reload systemd and enable/start the service
systemctl daemon-reload
systemctl enable patchmon-agent.service
systemctl start patchmon-agent.service
# Check if service started successfully
if systemctl is-active --quiet patchmon-agent.service; then
success "✅ PatchMon Agent service started successfully"
info "🔗 WebSocket connection established"
else
warning "⚠️ Service may have failed to start. Check status with: systemctl status patchmon-agent"
fi
SERVICE_TYPE="systemd"
elif [ -d /etc/init.d ] && command -v rc-service >/dev/null 2>&1; then
# OpenRC is available (Alpine Linux)
info "🔧 Setting up OpenRC service..."
# Stop and disable existing service if it exists
if rc-service patchmon-agent status >/dev/null 2>&1; then
warning "⚠️ Stopping existing PatchMon agent service..."
rc-service patchmon-agent stop
fi
if rc-update show default 2>/dev/null | grep -q "patchmon-agent"; then
warning "⚠️ Disabling existing PatchMon agent service..."
rc-update del patchmon-agent default
fi
# Create OpenRC service file
cat > /etc/init.d/patchmon-agent << 'EOF'
#!/sbin/openrc-run
# Clean up old crontab entries if they exist (from previous installations)
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
warning "⚠️ Found old crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
info "📋 Removed old crontab entries"
fi
name="patchmon-agent"
description="PatchMon Agent Service"
command="/usr/local/bin/patchmon-agent"
command_args="serve"
command_user="root"
pidfile="/var/run/patchmon-agent.pid"
command_background="yes"
working_dir="/etc/patchmon"
# Reload systemd and enable/start the service
systemctl daemon-reload
systemctl enable patchmon-agent.service
systemctl start patchmon-agent.service
# Check if service started successfully
if systemctl is-active --quiet patchmon-agent.service; then
success "✅ PatchMon Agent service started successfully"
info "🔗 WebSocket connection established"
depend() {
need net
after net
}
EOF
chmod +x /etc/init.d/patchmon-agent
# Clean up old crontab entries if they exist (from previous installations)
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
warning "⚠️ Found old crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
info "📋 Removed old crontab entries"
fi
# Enable and start the service
rc-update add patchmon-agent default
rc-service patchmon-agent start
# Check if service started successfully
if rc-service patchmon-agent status >/dev/null 2>&1; then
success "✅ PatchMon Agent service started successfully"
info "🔗 WebSocket connection established"
else
warning "⚠️ Service may have failed to start. Check status with: rc-service patchmon-agent status"
fi
SERVICE_TYPE="openrc"
else
warning "⚠️ Service may have failed to start. Check status with: systemctl status patchmon-agent"
# No init system detected, use crontab as fallback
warning "⚠️ No init system detected (systemd or OpenRC). Using crontab for service management."
# Clean up old crontab entries if they exist
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
warning "⚠️ Found old crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
info "📋 Removed old crontab entries"
fi
# Add crontab entry to run the agent
(crontab -l 2>/dev/null; echo "@reboot /usr/local/bin/patchmon-agent serve >/dev/null 2>&1") | crontab -
info "📋 Added crontab entry for PatchMon agent"
# Start the agent manually
/usr/local/bin/patchmon-agent serve >/dev/null 2>&1 &
success "✅ PatchMon Agent started in background"
info "🔗 WebSocket connection established"
SERVICE_TYPE="crontab"
fi
# Installation complete
@@ -680,14 +795,20 @@ echo " • Configuration directory: /etc/patchmon"
echo " • Agent binary installed: /usr/local/bin/patchmon-agent"
echo " • Architecture: $ARCHITECTURE"
echo " • Dependencies installed: jq, curl, bc"
echo " • Systemd service configured and running"
if [ "$SERVICE_TYPE" = "systemd" ]; then
echo " • Systemd service configured and running"
elif [ "$SERVICE_TYPE" = "openrc" ]; then
echo " • OpenRC service configured and running"
else
echo " • Service configured via crontab"
fi
echo " • API credentials configured and tested"
echo " • WebSocket connection established"
echo " • Logs directory: /etc/patchmon/logs"
# Check for moved files and show them
MOVED_FILES=$(ls /etc/patchmon/credentials.yml.backup.* /etc/patchmon/config.yml.backup.* /usr/local/bin/patchmon-agent.backup.* /etc/patchmon/logs/patchmon-agent.log.old.* /usr/local/bin/patchmon-agent.sh.backup.* /etc/patchmon/credentials.backup.* 2>/dev/null || true)
if [[ -n "$MOVED_FILES" ]]; then
if [ -n "$MOVED_FILES" ]; then
echo ""
echo -e "${YELLOW}📋 Files Moved for Fresh Installation:${NC}"
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 " • Manual report: /usr/local/bin/patchmon-agent report"
echo " • Check status: /usr/local/bin/patchmon-agent diagnostics"
echo " • Service status: systemctl status patchmon-agent"
echo " • Service logs: journalctl -u patchmon-agent -f"
echo " • Restart service: systemctl restart patchmon-agent"
if [ "$SERVICE_TYPE" = "systemd" ]; then
echo " • Service status: systemctl status patchmon-agent"
echo " • Service logs: journalctl -u patchmon-agent -f"
echo " • Restart service: systemctl restart patchmon-agent"
elif [ "$SERVICE_TYPE" = "openrc" ]; then
echo " • Service status: rc-service patchmon-agent status"
echo " • Service logs: tail -f /etc/patchmon/logs/patchmon-agent.log"
echo " • Restart service: rc-service patchmon-agent restart"
else
echo " • Service logs: tail -f /etc/patchmon/logs/patchmon-agent.log"
echo " • Restart service: pkill -f 'patchmon-agent serve' && /usr/local/bin/patchmon-agent serve &"
fi
echo ""
success "✅ Your system is now being monitored by PatchMon!"

View File

@@ -288,6 +288,7 @@ model auto_enrollment_tokens {
last_used_at DateTime?
expires_at DateTime?
metadata Json?
scopes Json?
users users? @relation(fields: [created_by_user_id], references: [id], onDelete: SetNull)
host_groups host_groups? @relation(fields: [default_host_group_id], references: [id], onDelete: SetNull)

View File

@@ -0,0 +1,113 @@
const { getPrismaClient } = require("../config/prisma");
const bcrypt = require("bcryptjs");
const prisma = getPrismaClient();
/**
* Middleware factory to authenticate API tokens using Basic Auth
* @param {string} integrationType - The expected integration type (e.g., "api", "gethomepage")
* @returns {Function} Express middleware function
*/
const authenticateApiToken = (integrationType) => {
return async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Basic ")) {
return res
.status(401)
.json({ error: "Missing or invalid authorization header" });
}
// Decode base64 credentials
const base64Credentials = authHeader.split(" ")[1];
const credentials = Buffer.from(base64Credentials, "base64").toString(
"ascii",
);
const [apiKey, apiSecret] = credentials.split(":");
if (!apiKey || !apiSecret) {
return res.status(401).json({ error: "Invalid credentials format" });
}
// Find the token in database
const token = await prisma.auto_enrollment_tokens.findUnique({
where: { token_key: apiKey },
include: {
users: {
select: {
id: true,
username: true,
role: true,
},
},
},
});
if (!token) {
console.log(`API key not found: ${apiKey}`);
return res.status(401).json({ error: "Invalid API key" });
}
// Check if token is active
if (!token.is_active) {
return res.status(401).json({ error: "API key is disabled" });
}
// Check if token has expired
if (token.expires_at && new Date(token.expires_at) < new Date()) {
return res.status(401).json({ error: "API key has expired" });
}
// Check if token is for the expected integration type
if (token.metadata?.integration_type !== integrationType) {
return res.status(401).json({ error: "Invalid API key type" });
}
// Verify the secret
const isValidSecret = await bcrypt.compare(apiSecret, token.token_secret);
if (!isValidSecret) {
return res.status(401).json({ error: "Invalid API secret" });
}
// Check IP restrictions if any
if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) {
const clientIp = req.ip || req.connection.remoteAddress;
const forwardedFor = req.headers["x-forwarded-for"];
const realIp = req.headers["x-real-ip"];
// Get the actual client IP (considering proxies)
const actualClientIp = forwardedFor
? forwardedFor.split(",")[0].trim()
: realIp || clientIp;
const isAllowedIp = token.allowed_ip_ranges.some((range) => {
// Simple IP range check (can be enhanced for CIDR support)
return actualClientIp.startsWith(range) || actualClientIp === range;
});
if (!isAllowedIp) {
console.log(
`IP validation failed. Client IP: ${actualClientIp}, Allowed ranges: ${token.allowed_ip_ranges.join(", ")}`,
);
return res.status(403).json({ error: "IP address not allowed" });
}
}
// Update last used timestamp
await prisma.auto_enrollment_tokens.update({
where: { id: token.id },
data: { last_used_at: new Date() },
});
// Attach token info to request
req.apiToken = token;
next();
} catch (error) {
console.error("API key authentication error:", error);
res.status(500).json({ error: "Authentication failed" });
}
};
};
module.exports = { authenticateApiToken };

View File

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

View File

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

View File

@@ -125,6 +125,10 @@ router.post(
.optional({ nullable: true, checkFalsy: true })
.isISO8601()
.withMessage("Invalid date format"),
body("scopes")
.optional()
.isObject()
.withMessage("Scopes must be an object"),
],
async (req, res) => {
try {
@@ -140,6 +144,7 @@ router.post(
default_host_group_id,
expires_at,
metadata = {},
scopes,
} = req.body;
// Validate host group if provided
@@ -153,6 +158,32 @@ router.post(
}
}
// Validate scopes for API tokens
if (metadata.integration_type === "api" && scopes) {
// Validate scopes structure
if (typeof scopes !== "object" || scopes === null) {
return res.status(400).json({ error: "Scopes must be an object" });
}
// Validate each resource in scopes
for (const [resource, actions] of Object.entries(scopes)) {
if (!Array.isArray(actions)) {
return res.status(400).json({
error: `Scopes for resource "${resource}" must be an array of actions`,
});
}
// Validate action names
for (const action of actions) {
if (typeof action !== "string") {
return res.status(400).json({
error: `All actions in scopes must be strings`,
});
}
}
}
}
const { token_key, token_secret } = generate_auto_enrollment_token();
const hashed_secret = await bcrypt.hash(token_secret, 10);
@@ -168,6 +199,7 @@ router.post(
default_host_group_id: default_host_group_id || null,
expires_at: expires_at ? new Date(expires_at) : null,
metadata: { integration_type: "proxmox-lxc", ...metadata },
scopes: metadata.integration_type === "api" ? scopes || null : null,
updated_at: new Date(),
},
include: {
@@ -201,6 +233,7 @@ router.post(
default_host_group: token.host_groups,
created_by: token.users,
expires_at: token.expires_at,
scopes: token.scopes,
},
warning: "⚠️ Save the token_secret now - it cannot be retrieved later!",
});
@@ -232,6 +265,7 @@ router.get(
created_at: true,
default_host_group_id: true,
metadata: true,
scopes: true,
host_groups: {
select: {
id: true,
@@ -314,6 +348,10 @@ router.patch(
body("max_hosts_per_day").optional().isInt({ min: 1, max: 1000 }),
body("allowed_ip_ranges").optional().isArray(),
body("expires_at").optional().isISO8601(),
body("scopes")
.optional()
.isObject()
.withMessage("Scopes must be an object"),
],
async (req, res) => {
try {
@@ -323,6 +361,16 @@ router.patch(
}
const { tokenId } = req.params;
// First, get the existing token to check its integration type
const existing_token = await prisma.auto_enrollment_tokens.findUnique({
where: { id: tokenId },
});
if (!existing_token) {
return res.status(404).json({ error: "Token not found" });
}
const update_data = { updated_at: new Date() };
if (req.body.is_active !== undefined)
@@ -334,6 +382,41 @@ router.patch(
if (req.body.expires_at !== undefined)
update_data.expires_at = new Date(req.body.expires_at);
// Handle scopes updates for API tokens only
if (req.body.scopes !== undefined) {
if (existing_token.metadata?.integration_type === "api") {
// Validate scopes structure
const scopes = req.body.scopes;
if (typeof scopes !== "object" || scopes === null) {
return res.status(400).json({ error: "Scopes must be an object" });
}
// Validate each resource in scopes
for (const [resource, actions] of Object.entries(scopes)) {
if (!Array.isArray(actions)) {
return res.status(400).json({
error: `Scopes for resource "${resource}" must be an array of actions`,
});
}
// Validate action names
for (const action of actions) {
if (typeof action !== "string") {
return res.status(400).json({
error: `All actions in scopes must be strings`,
});
}
}
}
update_data.scopes = scopes;
} else {
return res.status(400).json({
error: "Scopes can only be updated for API integration tokens",
});
}
}
const token = await prisma.auto_enrollment_tokens.update({
where: { id: tokenId },
data: update_data,

View File

@@ -1,113 +1,12 @@
const express = require("express");
const { getPrismaClient } = require("../config/prisma");
const bcrypt = require("bcryptjs");
const { authenticateApiToken } = require("../middleware/apiAuth");
const router = express.Router();
const prisma = getPrismaClient();
// Middleware to authenticate API key
const authenticateApiKey = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Basic ")) {
return res
.status(401)
.json({ error: "Missing or invalid authorization header" });
}
// Decode base64 credentials
const base64Credentials = authHeader.split(" ")[1];
const credentials = Buffer.from(base64Credentials, "base64").toString(
"ascii",
);
const [apiKey, apiSecret] = credentials.split(":");
if (!apiKey || !apiSecret) {
return res.status(401).json({ error: "Invalid credentials format" });
}
// Find the token in database
const token = await prisma.auto_enrollment_tokens.findUnique({
where: { token_key: apiKey },
include: {
users: {
select: {
id: true,
username: true,
role: true,
},
},
},
});
if (!token) {
console.log(`API key not found: ${apiKey}`);
return res.status(401).json({ error: "Invalid API key" });
}
// Check if token is active
if (!token.is_active) {
return res.status(401).json({ error: "API key is disabled" });
}
// Check if token has expired
if (token.expires_at && new Date(token.expires_at) < new Date()) {
return res.status(401).json({ error: "API key has expired" });
}
// Check if token is for gethomepage integration
if (token.metadata?.integration_type !== "gethomepage") {
return res.status(401).json({ error: "Invalid API key type" });
}
// Verify the secret
const isValidSecret = await bcrypt.compare(apiSecret, token.token_secret);
if (!isValidSecret) {
return res.status(401).json({ error: "Invalid API secret" });
}
// Check IP restrictions if any
if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) {
const clientIp = req.ip || req.connection.remoteAddress;
const forwardedFor = req.headers["x-forwarded-for"];
const realIp = req.headers["x-real-ip"];
// Get the actual client IP (considering proxies)
const actualClientIp = forwardedFor
? forwardedFor.split(",")[0].trim()
: realIp || clientIp;
const isAllowedIp = token.allowed_ip_ranges.some((range) => {
// Simple IP range check (can be enhanced for CIDR support)
return actualClientIp.startsWith(range) || actualClientIp === range;
});
if (!isAllowedIp) {
console.log(
`IP validation failed. Client IP: ${actualClientIp}, Allowed ranges: ${token.allowed_ip_ranges.join(", ")}`,
);
return res.status(403).json({ error: "IP address not allowed" });
}
}
// Update last used timestamp
await prisma.auto_enrollment_tokens.update({
where: { id: token.id },
data: { last_used_at: new Date() },
});
// Attach token info to request
req.apiToken = token;
next();
} catch (error) {
console.error("API key authentication error:", error);
res.status(500).json({ error: "Authentication failed" });
}
};
// Get homepage widget statistics
router.get("/stats", authenticateApiKey, async (_req, res) => {
router.get("/stats", authenticateApiToken("gethomepage"), async (_req, res) => {
try {
// Get total hosts count
const totalHosts = await prisma.hosts.count({
@@ -235,7 +134,7 @@ router.get("/stats", authenticateApiKey, async (_req, res) => {
});
// Health check endpoint for the API
router.get("/health", authenticateApiKey, async (req, res) => {
router.get("/health", authenticateApiToken("gethomepage"), async (req, res) => {
res.json({
status: "ok",
timestamp: new Date().toISOString(),

View File

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

View File

@@ -28,6 +28,8 @@ 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("");
@@ -40,6 +42,9 @@ const Integrations = () => {
default_host_group_id: "",
allowed_ip_ranges: "",
expires_at: "",
scopes: {
host: [],
},
});
const [copy_success, setCopySuccess] = useState({});
@@ -54,6 +59,25 @@ 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();
@@ -96,6 +120,14 @@ 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),
@@ -103,8 +135,7 @@ const Integrations = () => {
? form_data.allowed_ip_ranges.split(",").map((ip) => ip.trim())
: [],
metadata: {
integration_type:
activeTab === "gethomepage" ? "gethomepage" : "proxmox-lxc",
integration_type: integration_type,
},
};
@@ -116,6 +147,11 @@ 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);
@@ -128,6 +164,9 @@ const Integrations = () => {
default_host_group_id: "",
allowed_ip_ranges: "",
expires_at: "",
scopes: {
host: [],
},
});
} catch (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) => {
// Check if Clipboard API is available
if (navigator.clipboard && window.isSecureContext) {
@@ -256,6 +358,17 @@ 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")}
@@ -736,6 +849,214 @@ 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 />
&nbsp;&nbsp;{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 />
&nbsp;&nbsp;"{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">
@@ -885,7 +1206,9 @@ const Integrations = () => {
<h2 className="text-xl font-bold text-secondary-900 dark:text-white">
{activeTab === "gethomepage"
? "Create GetHomepage API Key"
: "Create Auto-Enrollment Token"}
: activeTab === "api"
? "Create API Credential"
: "Create Auto-Enrollment Token"}
</h2>
<button
type="button"
@@ -911,7 +1234,9 @@ const Integrations = () => {
placeholder={
activeTab === "gethomepage"
? "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"
/>
@@ -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">
<span className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Allowed IP Addresses (Optional)
@@ -1038,7 +1413,9 @@ const Integrations = () => {
<h2 className="text-lg font-bold text-secondary-900 dark:text-white">
{activeTab === "gethomepage"
? "API Key Created Successfully"
: "Token Created Successfully"}
: activeTab === "api"
? "API Credential Created Successfully"
: "Token Created Successfully"}
</h2>
</div>
<button
@@ -1161,6 +1538,103 @@ 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">
@@ -1371,6 +1845,154 @@ 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>
);
};