commit c5332ce6b0bd0e04f9b944fd5deb70da5885ae31 Author: Muhammad Ibrahim Date: Tue Sep 16 15:36:42 2025 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd47193 --- /dev/null +++ b/.gitignore @@ -0,0 +1,137 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.production + +# Build outputs +dist/ +build/ +out/ + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Runtime data +pids/ +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt + +# Gatsby files +.cache/ +public + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Editor directories and files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Database +*.db +*.sqlite +*.sqlite3 + +# Prisma +prisma/migrations/dev.db* + +# Backend specific +backend/logs/ +backend/uploads/ +backend/temp/ + +# Frontend specific +frontend/dist/ +frontend/.vite/ + +# Agent specific +agents/*.log + +# SSL certificates +*.pem +*.key +*.crt +*.csr + +# Backup files +*.bak +*.backup +*.old + +# Test files +test-results/ +playwright-report/ +test-results.xml + +# Package manager lock files (uncomment if you want to ignore them) +# package-lock.json +# yarn.lock +# pnpm-lock.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b533d9 --- /dev/null +++ b/README.md @@ -0,0 +1,434 @@ +# PatchMon - Linux Patch Monitoring System + +A comprehensive system for monitoring Linux package updates across multiple hosts with a modern web interface and automated agent deployment. + +## Features + +- **Multi-Host Monitoring**: Monitor package updates across multiple Linux servers +- **Real-time Dashboard**: Web-based dashboard with statistics and host management +- **Automated Agents**: Lightweight agents for automatic data collection +- **Host Grouping**: Organize hosts into groups for better management +- **Repository Tracking**: Monitor APT/YUM repositories and their usage +- **Security Updates**: Track security-specific package updates +- **User Management**: Role-based access control with granular permissions +- **Dark Mode**: Modern UI with dark/light theme support +- **Agent Versioning**: Manage and auto-update agent versions +- **API Credentials**: Secure agent authentication system + +## Prerequisites + +- **Node.js**: 18.0.0 or higher +- **PostgreSQL**: 12 or higher +- **Linux**: Ubuntu, Debian, CentOS, RHEL, or Fedora (for agents) + +## Quick Start + +### 1. Clone and Install + +```bash +git clone +cd patchmon +npm install +``` + +### 2. Database Setup + +Create a PostgreSQL database: + +```sql +CREATE DATABASE patchmon; +CREATE USER patchmon_user WITH PASSWORD 'your_secure_password'; +GRANT ALL PRIVILEGES ON DATABASE patchmon TO patchmon_user; +``` + +### 3. Environment Configuration + +Create `.env` file in the project root: + +```bash +# Database +DATABASE_URL="postgresql://patchmon_user:your_secure_password@localhost:5432/patchmon?schema=public" + +# Backend +NODE_ENV=production +PORT=3001 +API_VERSION=v1 + +# Security +CORS_ORIGINS=https://your-frontend.example +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=100 +AUTH_RATE_LIMIT_WINDOW_MS=600000 +AUTH_RATE_LIMIT_MAX=20 +AGENT_RATE_LIMIT_WINDOW_MS=60000 +AGENT_RATE_LIMIT_MAX=120 +ENABLE_HSTS=true +TRUST_PROXY=1 +JSON_BODY_LIMIT=5mb +ENABLE_LOGGING=false +LOG_LEVEL=info + +# JWT Secret (generate a strong secret) +JWT_SECRET=your-super-secure-jwt-secret-here + +# Frontend +VITE_API_URL=https://your-api.example/api/v1 +``` + +### 4. Database Migration + +```bash +cd backend +npx prisma migrate deploy +npx prisma generate +``` + +### 5. Create Admin User + +```bash +cd backend +node -e " +const { PrismaClient } = require('@prisma/client'); +const bcrypt = require('bcryptjs'); +const prisma = new PrismaClient(); + +async function createAdmin() { + const hashedPassword = await bcrypt.hash('admin123', 10); + await prisma.user.create({ + data: { + username: 'admin', + email: 'admin@example.com', + password: hashedPassword, + role: 'admin' + } + }); + console.log('Admin user created: admin / admin123'); + await prisma.\$disconnect(); +} + +createAdmin().catch(console.error); +" +``` + +### 6. Start Services + +**Development:** +```bash +# Start both backend and frontend +npm run dev + +# Or start individually +npm run dev:backend +npm run dev:frontend +``` + +**Production:** +```bash +# Build frontend +npm run build:frontend + +# Start backend +cd backend +npm start +``` + +### 7. Access the Application + +- **Frontend**: http://localhost:3000 +- **Backend API**: http://localhost:3001 +- **Default Login**: admin / admin123 + +## Agent Installation + +### Automatic Installation + +1. **Create a Host** in the web interface +2. **Copy the installation command** from the host detail page +3. **Run on your Linux server**: + +```bash +curl -sSL https://your-patchmon-server.com/api/v1/hosts/install | bash -s -- your-api-id your-api-key +``` + +### Manual Installation + +1. **Download the agent script**: +```bash +wget https://your-patchmon-server.com/api/v1/hosts/agent/download +chmod +x patchmon-agent.sh +sudo mv patchmon-agent.sh /usr/local/bin/ +``` + +2. **Configure with API credentials**: +```bash +sudo /usr/local/bin/patchmon-agent.sh configure your-api-id your-api-key +``` + +3. **Test the connection**: +```bash +sudo /usr/local/bin/patchmon-agent.sh test +``` + +4. **Start monitoring**: +```bash +sudo /usr/local/bin/patchmon-agent.sh update +``` + +## Configuration + +### Backend Configuration + +Key environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATABASE_URL` | Required | PostgreSQL connection string | +| `JWT_SECRET` | Required | Secret for JWT token signing | +| `CORS_ORIGINS` | `http://localhost:3000` | Comma-separated allowed origins | +| `RATE_LIMIT_MAX` | `100` | Max requests per window | +| `RATE_LIMIT_WINDOW_MS` | `900000` | Rate limit window (15 min) | +| `ENABLE_LOGGING` | `false` | Enable file logging | +| `TRUST_PROXY` | `1` | Trust reverse proxy headers | + +### Frontend Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `VITE_API_URL` | `/api/v1` | Backend API URL | +| `VITE_ENABLE_LOGGING` | `false` | Enable dev server logging | + +### Agent Configuration + +The agent automatically configures itself with: +- **Update interval**: Set in PatchMon settings (default: 60 minutes) +- **Auto-update**: Can be enabled per-host or globally +- **Repository tracking**: Automatically detects APT/YUM repositories + +## User Management + +### Roles and Permissions + +- **Admin**: Full system access +- **Manager**: Host and package management +- **Viewer**: Read-only access + +### Creating Users + +1. **Via Web Interface**: Admin → Users → Add User +2. **Via API**: POST `/api/v1/auth/admin/users` + +## Host Management + +### Adding Hosts + +1. **Web Interface**: Hosts → Add Host +2. **API**: POST `/api/v1/hosts/create` + +### Host Groups + +Organize hosts into groups for better management: +- Create groups in Host Groups section +- Assign hosts to groups +- View group-specific statistics + +## Monitoring and Alerts + +### Dashboard + +- **Overview**: Total hosts, packages, updates needed +- **Host Status**: Online/offline status +- **Update Statistics**: Security updates, regular updates +- **Recent Activity**: Latest host updates + +### Package Management + +- **Package List**: All packages across all hosts +- **Update Status**: Which packages need updates +- **Security Updates**: Critical security patches +- **Host Dependencies**: Which hosts use specific packages + +## Security Features + +### Authentication + +- **JWT Tokens**: Secure session management +- **API Credentials**: Per-host authentication +- **Password Hashing**: bcrypt with salt rounds + +### Security Headers + +- **Helmet.js**: Security headers (CSP, HSTS, etc.) +- **CORS**: Configurable origin restrictions +- **Rate Limiting**: Per-route rate limits +- **Input Validation**: express-validator on all endpoints + +### Agent Security + +- **HTTPS Only**: Agents use HTTPS for communication +- **API Key Rotation**: Regenerate credentials when needed +- **Secure Storage**: Credentials stored in protected files + +## Troubleshooting + +### Common Issues + +**Agent Connection Failed:** +```bash +# Check agent configuration +sudo /usr/local/bin/patchmon-agent.sh test + +# Verify API credentials +sudo /usr/local/bin/patchmon-agent.sh ping +``` + +**Database Connection Issues:** +```bash +# Test database connection +cd backend +npx prisma db push + +# Check migration status +npx prisma migrate status +``` + +**Frontend Build Issues:** +```bash +# Clear node_modules and reinstall +rm -rf node_modules package-lock.json +npm install + +# Rebuild +npm run build:frontend +``` + +### Logs + +**Backend Logs:** +- Enable logging: `ENABLE_LOGGING=true` +- Log files: `backend/logs/` + +**Agent Logs:** +- Log file: `/var/log/patchmon-agent.log` +- Debug mode: `sudo /usr/local/bin/patchmon-agent.sh diagnostics` + +## Development + +### Project Structure + +``` +patchmon/ +├── backend/ # Express.js API server +│ ├── src/ +│ │ ├── routes/ # API route handlers +│ │ ├── middleware/ # Authentication, validation +│ │ └── server.js # Main server file +│ ├── prisma/ # Database schema and migrations +│ └── package.json +├── frontend/ # React.js web application +│ ├── src/ +│ │ ├── components/ # Reusable UI components +│ │ ├── pages/ # Page components +│ │ ├── contexts/ # React contexts +│ │ └── utils/ # Utility functions +│ └── package.json +├── agents/ # Agent scripts +│ └── patchmon-agent.sh # Main agent script +└── README.md +``` + +### API Documentation + +**Authentication:** +- `POST /api/v1/auth/login` - User login +- `POST /api/v1/auth/logout` - User logout +- `GET /api/v1/auth/me` - Get current user + +**Hosts:** +- `GET /api/v1/hosts` - List hosts +- `POST /api/v1/hosts/create` - Create host +- `POST /api/v1/hosts/update` - Agent update (API credentials) +- `DELETE /api/v1/hosts/:id` - Delete host + +**Packages:** +- `GET /api/v1/packages` - List packages +- `GET /api/v1/packages/:id` - Get package details +- `GET /api/v1/packages/search/:query` - Search packages + +## Production Deployment + +### Reverse Proxy (Nginx) + +```nginx +server { + listen 443 ssl http2; + server_name your-patchmon.example.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + # Frontend + location / { + root /path/to/patchmon/frontend/dist; + try_files $uri $uri/ /index.html; + } + + # Backend API + location /api/ { + proxy_pass http://localhost:3001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### Systemd Service + +Create `/etc/systemd/system/patchmon.service`: + +```ini +[Unit] +Description=PatchMon Backend +After=network.target + +[Service] +Type=simple +User=patchmon +WorkingDirectory=/path/to/patchmon/backend +ExecStart=/usr/bin/node src/server.js +Restart=always +Environment=NODE_ENV=production + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: +```bash +sudo systemctl enable patchmon +sudo systemctl start patchmon +``` + +## License + +[Add your license information here] + +## Support + +For issues and questions: +- Create an issue in the repository +- Check the troubleshooting section +- Review agent logs for connection issues + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request + +--- + +**Note**: Remember to change default passwords and secrets before deploying to production! diff --git a/agents/patchmon-agent.sh b/agents/patchmon-agent.sh new file mode 100755 index 0000000..7ab2df2 --- /dev/null +++ b/agents/patchmon-agent.sh @@ -0,0 +1,1097 @@ +#!/bin/bash + +# PatchMon Agent Script +# This script sends package update information to the PatchMon server using API credentials + +# Configuration +PATCHMON_SERVER="http://localhost:3001" +API_VERSION="v1" +AGENT_VERSION="1.2.2" +CONFIG_FILE="/etc/patchmon/agent.conf" +CREDENTIALS_FILE="/etc/patchmon/credentials" +LOG_FILE="/var/log/patchmon-agent.log" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Logging function +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" +} + +# Error handling +error() { + echo -e "${RED}ERROR: $1${NC}" >&2 + log "ERROR: $1" + exit 1 +} + +# Info logging +info() { + echo -e "${BLUE}INFO: $1${NC}" + log "INFO: $1" +} + +# Success logging +success() { + echo -e "${GREEN}SUCCESS: $1${NC}" + log "SUCCESS: $1" +} + +# Warning logging +warning() { + echo -e "${YELLOW}WARNING: $1${NC}" + log "WARNING: $1" +} + +# Check if running as root +check_root() { + if [[ $EUID -ne 0 ]]; then + error "This script must be run as root" + fi +} + +# Create necessary directories +setup_directories() { + mkdir -p /etc/patchmon + mkdir -p /var/log + touch "$LOG_FILE" + chmod 600 "$LOG_FILE" +} + +# Load configuration +load_config() { + if [[ -f "$CONFIG_FILE" ]]; then + source "$CONFIG_FILE" + fi +} + +# Load API credentials +load_credentials() { + if [[ ! -f "$CREDENTIALS_FILE" ]]; then + error "Credentials file not found at $CREDENTIALS_FILE. Please configure API credentials first." + fi + + source "$CREDENTIALS_FILE" + + if [[ -z "$API_ID" ]] || [[ -z "$API_KEY" ]]; then + error "API_ID and API_KEY must be configured in $CREDENTIALS_FILE" + fi + + # Use PATCHMON_URL from credentials if available, otherwise use default + if [[ -n "$PATCHMON_URL" ]]; then + PATCHMON_SERVER="$PATCHMON_URL" + fi +} + +# Configure API credentials +configure_credentials() { + info "Setting up API credentials..." + + if [[ -z "$1" ]] || [[ -z "$2" ]]; then + echo "Usage: $0 configure [SERVER_URL]" + echo "" + echo "Example:" + echo " $0 configure patchmon_1a2b3c4d abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + echo " $0 configure patchmon_1a2b3c4d abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890 http://patchmon.example.com" + echo "" + echo "Contact your PatchMon administrator to get your API credentials." + exit 1 + fi + + local api_id="$1" + local api_key="$2" + local server_url="${3:-$PATCHMON_SERVER}" + + # Validate API ID format + if [[ ! "$api_id" =~ ^patchmon_[a-f0-9]{16}$ ]]; then + error "Invalid API ID format. API ID should be in format: patchmon_xxxxxxxxxxxxxxxx" + fi + + # Validate API Key format (64 hex characters) + if [[ ! "$api_key" =~ ^[a-f0-9]{64}$ ]]; then + error "Invalid API Key format. API Key should be 64 hexadecimal characters." + fi + + # Validate server URL format + if [[ ! "$server_url" =~ ^https?:// ]]; then + error "Invalid server URL format. Must start with http:// or https://" + fi + + # Create credentials file + cat > "$CREDENTIALS_FILE" << EOF +# PatchMon API Credentials +# Generated on $(date) +PATCHMON_URL="$server_url" +API_ID="$api_id" +API_KEY="$api_key" +EOF + + chmod 600 "$CREDENTIALS_FILE" + success "API credentials configured successfully" + info "Credentials saved to: $CREDENTIALS_FILE" + + # Test credentials + info "Testing API credentials..." + test_credentials +} + +# Test API credentials +test_credentials() { + load_credentials + + local response=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -H "X-API-ID: $API_ID" \ + -H "X-API-KEY: $API_KEY" \ + "$PATCHMON_SERVER/api/$API_VERSION/hosts/ping") + + if [[ $? -eq 0 ]] && echo "$response" | grep -q "success"; then + success "API credentials are valid" + local hostname=$(echo "$response" | grep -o '"hostname":"[^"]*' | cut -d'"' -f4) + if [[ -n "$hostname" ]]; then + info "Connected as host: $hostname" + fi + else + error "API credentials test failed: $response" + fi +} + +# Detect OS and version +detect_os() { + if [[ -f /etc/os-release ]]; then + source /etc/os-release + OS_TYPE=$(echo "$ID" | tr '[:upper:]' '[:lower:]') + OS_VERSION="$VERSION_ID" + + # Map OS variations to their appropriate categories + case "$OS_TYPE" in + "pop"|"linuxmint"|"elementary") + OS_TYPE="ubuntu" + ;; + "opensuse"|"opensuse-leap"|"opensuse-tumbleweed") + OS_TYPE="suse" + ;; + "rocky"|"almalinux") + OS_TYPE="rhel" + ;; + esac + + elif [[ -f /etc/redhat-release ]]; then + if grep -q "CentOS" /etc/redhat-release; then + OS_TYPE="centos" + elif grep -q "Red Hat" /etc/redhat-release; then + OS_TYPE="rhel" + fi + OS_VERSION=$(grep -oE '[0-9]+\.[0-9]+' /etc/redhat-release | head -1) + else + error "Unable to detect OS version" + fi + + ARCHITECTURE=$(uname -m) + HOSTNAME=$(hostname) + IP_ADDRESS=$(hostname -I | awk '{print $1}') +} + +# Get repository information based on OS +get_repository_info() { + local repos_json="[" + local first=true + + case "$OS_TYPE" in + "ubuntu"|"debian") + get_apt_repositories repos_json first + ;; + "centos"|"rhel"|"fedora") + get_yum_repositories repos_json first + ;; + *) + # Return empty array for unsupported OS + ;; + esac + + repos_json+="]" + echo "$repos_json" +} + +# Get repository info for APT-based systems +get_apt_repositories() { + local -n repos_ref=$1 + local -n first_ref=$2 + + # Parse traditional .list files + local sources_files="/etc/apt/sources.list" + if [[ -d "/etc/apt/sources.list.d" ]]; then + sources_files="$sources_files $(find /etc/apt/sources.list.d -name '*.list' 2>/dev/null)" + fi + + for file in $sources_files; do + if [[ -f "$file" ]]; then + while IFS= read -r line; do + # Skip comments and empty lines + if [[ "$line" =~ ^[[:space:]]*# ]] || [[ -z "$line" ]]; then + continue + fi + + # Parse repository line (deb or deb-src) + if [[ "$line" =~ ^[[:space:]]*(deb|deb-src)[[:space:]]+ ]]; then + # Clean the line and extract components + local clean_line=$(echo "$line" | xargs) + local repo_type=$(echo "$clean_line" | awk '{print $1}') + + # Handle modern APT format with options like [signed-by=...] + local url="" + local distribution="" + local components="" + + if [[ "$clean_line" =~ \[.*\] ]]; then + # Modern format: deb [options] URL distribution components + # Extract URL (first field after the options) + url=$(echo "$clean_line" | sed 's/deb[^[:space:]]* \[[^]]*\] //' | awk '{print $1}') + distribution=$(echo "$clean_line" | sed 's/deb[^[:space:]]* \[[^]]*\] //' | awk '{print $2}') + components=$(echo "$clean_line" | sed 's/deb[^[:space:]]* \[[^]]*\] [^[:space:]]* [^[:space:]]* //') + else + # Traditional format: deb URL distribution components + url=$(echo "$clean_line" | awk '{print $2}') + distribution=$(echo "$clean_line" | awk '{print $3}') + components=$(echo "$clean_line" | cut -d' ' -f4- | xargs) + fi + + # Skip if URL doesn't look like a valid URL + if [[ ! "$url" =~ ^https?:// ]] && [[ ! "$url" =~ ^ftp:// ]]; then + continue + fi + + # Skip if distribution is empty or looks malformed + if [[ -z "$distribution" ]] || [[ "$distribution" =~ \[.*\] ]]; then + continue + fi + + # Determine if repository uses HTTPS + local is_secure=false + if [[ "$url" =~ ^https:// ]]; then + is_secure=true + fi + + # Generate repository name from URL and distribution + local repo_name="$distribution" + + # Extract meaningful name from URL for better identification + if [[ "$url" =~ archive\.ubuntu\.com ]]; then + repo_name="ubuntu-$distribution" + elif [[ "$url" =~ security\.ubuntu\.com ]]; then + repo_name="ubuntu-$distribution-security" + elif [[ "$url" =~ deb\.nodesource\.com ]]; then + repo_name="nodesource-$distribution" + elif [[ "$url" =~ packagecloud\.io ]]; then + repo_name="packagecloud-$(echo "$url" | cut -d'/' -f4-5 | tr '/' '-')" + elif [[ "$url" =~ ppa\.launchpad ]]; then + repo_name="ppa-$(echo "$url" | cut -d'/' -f4-5 | tr '/' '-')" + elif [[ "$url" =~ packages\.microsoft\.com ]]; then + repo_name="microsoft-$(echo "$url" | cut -d'/' -f4-)" + elif [[ "$url" =~ download\.docker\.com ]]; then + repo_name="docker-$distribution" + else + # Fallback: use domain name + distribution + local domain=$(echo "$url" | cut -d'/' -f3 | cut -d':' -f1) + repo_name="$domain-$distribution" + fi + + # Add component suffix if relevant + if [[ "$components" =~ updates ]]; then + repo_name="$repo_name-updates" + elif [[ "$components" =~ security ]]; then + repo_name="$repo_name-security" + elif [[ "$components" =~ backports ]]; then + repo_name="$repo_name-backports" + fi + + if [[ "$first_ref" == true ]]; then + first_ref=false + else + repos_ref+="," + fi + + repos_ref+="{\"name\":\"$repo_name\",\"url\":\"$url\",\"distribution\":\"$distribution\",\"components\":\"$components\",\"repoType\":\"$repo_type\",\"isEnabled\":true,\"isSecure\":$is_secure}" + fi + done < "$file" + fi + done + + # Parse modern DEB822 format (.sources files) + if [[ -d "/etc/apt/sources.list.d" ]]; then + local sources_files_deb822=$(find /etc/apt/sources.list.d -name '*.sources' 2>/dev/null) + for file in $sources_files_deb822; do + if [[ -f "$file" ]]; then + local deb822_result=$(parse_deb822_sources_simple "$file") + if [[ -n "$deb822_result" ]]; then + if [[ "$first_ref" == true ]]; then + first_ref=false + repos_ref+="$deb822_result" + else + repos_ref+=",$deb822_result" + fi + fi + fi + done + fi +} + +# Simple DEB822 parser that returns JSON string +parse_deb822_sources_simple() { + local file=$1 + local result="" + local enabled="" + local types="" + local uris="" + local suites="" + local components="" + local name="" + local first_entry=true + + while IFS= read -r line; do + # Skip empty lines and comments + if [[ -z "$line" ]] || [[ "$line" =~ ^[[:space:]]*# ]]; then + continue + fi + + # Parse key-value pairs + if [[ "$line" =~ ^([^:]+):[[:space:]]*(.*)$ ]]; then + local key="${BASH_REMATCH[1]}" + local value="${BASH_REMATCH[2]}" + + case "$key" in + "Enabled") + enabled="$value" + ;; + "Types") + types="$value" + ;; + "URIs") + uris="$value" + ;; + "Suites") + suites="$value" + ;; + "Components") + components="$value" + ;; + "X-Repolib-Name") + name="$value" + ;; + esac + fi + + # Process repository entry when we hit a blank line + if [[ -z "$line" ]] || [[ "$line" =~ ^[[:space:]]*$ ]]; then + if [[ -n "$uris" && -n "$suites" && "$enabled" == "yes" ]]; then + local entry_result=$(process_deb822_entry_simple "$name" "$types" "$uris" "$suites" "$components") + if [[ -n "$entry_result" ]]; then + if [[ "$first_entry" == true ]]; then + first_entry=false + result="$entry_result" + else + result="$result,$entry_result" + fi + fi + fi + # Reset variables for next entry + enabled="" + types="" + uris="" + suites="" + components="" + name="" + fi + done < "$file" + + # Process the last entry if file doesn't end with blank line + if [[ -n "$uris" && -n "$suites" && "$enabled" == "yes" ]]; then + local entry_result=$(process_deb822_entry_simple "$name" "$types" "$uris" "$suites" "$components") + if [[ -n "$entry_result" ]]; then + if [[ "$first_entry" == true ]]; then + result="$entry_result" + else + result="$result,$entry_result" + fi + fi + fi + + echo "$result" +} + +# Process a DEB822 repository entry and return JSON +process_deb822_entry_simple() { + local name=$1 + local types=$2 + local uris=$3 + local suites=$4 + local components=$5 + local result="" + local first_entry=true + + # Handle multiple URIs + for uri in $uris; do + # Skip if URI doesn't look like a valid URL + if [[ ! "$uri" =~ ^https?:// ]] && [[ ! "$uri" =~ ^ftp:// ]]; then + continue + fi + + # Handle multiple suites + for suite in $suites; do + # Skip if suite looks malformed + if [[ -z "$suite" ]]; then + continue + fi + + # Determine if repository uses HTTPS + local is_secure=false + if [[ "$uri" =~ ^https:// ]]; then + is_secure=true + fi + + # Generate repository name + local repo_name="" + if [[ -n "$name" ]]; then + repo_name=$(echo "$name" | tr ' ' '-' | tr '[:upper:]' '[:lower:]') + else + repo_name="$suite" + fi + + # Extract meaningful name from URI for better identification + if [[ "$uri" =~ apt\.pop-os\.org/ubuntu ]]; then + repo_name="pop-os-ubuntu-$suite" + elif [[ "$uri" =~ apt\.pop-os\.org/release ]]; then + repo_name="pop-os-release-$suite" + elif [[ "$uri" =~ apt\.pop-os\.org/proprietary ]]; then + repo_name="pop-os-apps-$suite" + elif [[ "$uri" =~ archive\.ubuntu\.com ]]; then + repo_name="ubuntu-$suite" + elif [[ "$uri" =~ security\.ubuntu\.com ]]; then + repo_name="ubuntu-$suite-security" + else + # Fallback: use domain name + suite + local domain=$(echo "$uri" | cut -d'/' -f3 | cut -d':' -f1) + repo_name="$domain-$suite" + fi + + # Add component suffix if relevant and not already included + if [[ "$suite" != *"security"* && "$components" =~ security ]]; then + repo_name="$repo_name-security" + elif [[ "$suite" != *"updates"* && "$components" =~ updates ]]; then + repo_name="$repo_name-updates" + elif [[ "$suite" != *"backports"* && "$components" =~ backports ]]; then + repo_name="$repo_name-backports" + fi + + # Determine repo type (prefer deb over deb-src) + local repo_type="deb" + if [[ "$types" =~ deb-src ]] && [[ ! "$types" =~ ^deb[[:space:]] ]]; then + repo_type="deb-src" + fi + + local json_entry="{\"name\":\"$repo_name\",\"url\":\"$uri\",\"distribution\":\"$suite\",\"components\":\"$components\",\"repoType\":\"$repo_type\",\"isEnabled\":true,\"isSecure\":$is_secure}" + + if [[ "$first_entry" == true ]]; then + first_entry=false + result="$json_entry" + else + result="$result,$json_entry" + fi + done + done + + echo "$result" +} + +# Get repository info for YUM-based systems +get_yum_repositories() { + local -n repos_ref=$1 + local -n first_ref=$2 + + # Parse yum/dnf repository configuration + if command -v dnf >/dev/null 2>&1; then + local repo_info=$(dnf repolist all --verbose 2>/dev/null | grep -E "^Repo-id|^Repo-baseurl|^Repo-name|^Repo-status") + elif command -v yum >/dev/null 2>&1; then + local repo_info=$(yum repolist all -v 2>/dev/null | grep -E "^Repo-id|^Repo-baseurl|^Repo-name|^Repo-status") + fi + + # This is a simplified implementation - would need more work for full YUM support + # For now, return empty for non-APT systems +} + +# Get package information based on OS +get_package_info() { + local packages_json="[" + local first=true + + case "$OS_TYPE" in + "ubuntu"|"debian") + get_apt_packages packages_json first + ;; + "centos"|"rhel"|"fedora") + get_yum_packages packages_json first + ;; + *) + error "Unsupported OS type: $OS_TYPE" + ;; + esac + + packages_json+="]" + echo "$packages_json" +} + +# Get package info for APT-based systems +get_apt_packages() { + local -n packages_ref=$1 + local -n first_ref=$2 + + # Update package lists + apt-get update -qq + + # Get upgradable packages + local upgradable=$(apt list --upgradable 2>/dev/null | grep -v "WARNING") + + while IFS= read -r line; do + if [[ "$line" =~ ^([^/]+)/([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+.*[[:space:]]([^[:space:]]+)[[:space:]]*(\[.*\])? ]]; then + local package_name="${BASH_REMATCH[1]}" + local current_version="${BASH_REMATCH[4]}" + local available_version="${BASH_REMATCH[3]}" + local is_security_update=false + + # Check if it's a security update + if echo "$line" | grep -q "security"; then + is_security_update=true + fi + + if [[ "$first_ref" == true ]]; then + first_ref=false + else + packages_ref+="," + fi + + packages_ref+="{\"name\":\"$package_name\",\"currentVersion\":\"$current_version\",\"availableVersion\":\"$available_version\",\"needsUpdate\":true,\"isSecurityUpdate\":$is_security_update}" + fi + done <<< "$upgradable" + + # Get installed packages that are up to date + local installed=$(dpkg-query -W -f='${Package} ${Version}\n' | head -100) + + while IFS=' ' read -r package_name version; do + if [[ -n "$package_name" && -n "$version" ]]; then + # Check if this package is not in the upgrade list + if ! echo "$upgradable" | grep -q "^$package_name/"; then + if [[ "$first_ref" == true ]]; then + first_ref=false + else + packages_ref+="," + fi + + packages_ref+="{\"name\":\"$package_name\",\"currentVersion\":\"$version\",\"needsUpdate\":false,\"isSecurityUpdate\":false}" + fi + fi + done <<< "$installed" +} + +# Get package info for YUM/DNF-based systems +get_yum_packages() { + local -n packages_ref=$1 + local -n first_ref=$2 + + local package_manager="yum" + if command -v dnf &> /dev/null; then + package_manager="dnf" + fi + + # Get upgradable packages + local upgradable=$($package_manager check-update 2>/dev/null | grep -v "^$" | grep -v "^Loaded" | grep -v "^Last metadata" | tail -n +2) + + while IFS= read -r line; do + if [[ "$line" =~ ^([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+) ]]; then + local package_name="${BASH_REMATCH[1]}" + local available_version="${BASH_REMATCH[2]}" + local repo="${BASH_REMATCH[3]}" + + # Get current version + local current_version=$($package_manager list installed "$package_name" 2>/dev/null | grep "^$package_name" | awk '{print $2}') + + local is_security_update=false + if echo "$repo" | grep -q "security"; then + is_security_update=true + fi + + if [[ "$first_ref" == true ]]; then + first_ref=false + else + packages_ref+="," + fi + + packages_ref+="{\"name\":\"$package_name\",\"currentVersion\":\"$current_version\",\"availableVersion\":\"$available_version\",\"needsUpdate\":true,\"isSecurityUpdate\":$is_security_update}" + fi + done <<< "$upgradable" + + # Get some installed packages that are up to date + local installed=$($package_manager list installed 2>/dev/null | grep -v "^Loaded" | grep -v "^Installed" | head -100) + + while IFS= read -r line; do + if [[ "$line" =~ ^([^[:space:]]+)[[:space:]]+([^[:space:]]+) ]]; then + local package_name="${BASH_REMATCH[1]}" + local version="${BASH_REMATCH[2]}" + + # Check if this package is not in the upgrade list + if ! echo "$upgradable" | grep -q "^$package_name "; then + if [[ "$first_ref" == true ]]; then + first_ref=false + else + packages_ref+="," + fi + + packages_ref+="{\"name\":\"$package_name\",\"currentVersion\":\"$version\",\"needsUpdate\":false,\"isSecurityUpdate\":false}" + fi + fi + done <<< "$installed" +} + +# Send package update to server +send_update() { + load_credentials + + info "Collecting package information..." + local packages_json=$(get_package_info) + + info "Collecting repository information..." + local repositories_json=$(get_repository_info) + + info "Sending update to PatchMon server..." + + local payload=$(cat </dev/null; then + # Replace current script + mv "/tmp/patchmon-agent-new.sh" "$0" + chmod +x "$0" + success "Agent updated successfully" + info "Backup saved as: $0.backup.$(date +%Y%m%d_%H%M%S)" + + # Get the new version number + local new_version=$(grep '^AGENT_VERSION=' "$0" | cut -d'"' -f2) + info "Updated to version: $new_version" + + # Automatically run update to send new information to PatchMon + info "Sending updated information to PatchMon..." + if "$0" update; then + success "Successfully sent updated information to PatchMon" + else + warning "Failed to send updated information to PatchMon (this is not critical)" + fi + else + error "Downloaded script is invalid" + rm -f "/tmp/patchmon-agent-new.sh" + fi + else + error "Failed to download new agent script" + fi + else + error "Failed to get update information" + fi +} + +# Update crontab with current policy +update_crontab() { + load_credentials + info "Updating crontab with current policy..." + local response=$(curl -s -X GET "$PATCHMON_SERVER/api/$API_VERSION/settings/update-interval") + if [[ $? -eq 0 ]]; then + local update_interval=$(echo "$response" | grep -o '"updateInterval":[0-9]*' | cut -d':' -f2) + if [[ -n "$update_interval" ]]; then + # Generate the expected crontab entry + local expected_crontab="" + if [[ $update_interval -eq 60 ]]; then + # Hourly updates + expected_crontab="0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" + else + # Custom interval updates + expected_crontab="*/$update_interval * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" + fi + + # Get current crontab + local current_crontab=$(crontab -l 2>/dev/null | grep "patchmon-agent.sh update" | head -1) + + # Check if crontab needs updating + if [[ "$current_crontab" == "$expected_crontab" ]]; then + info "Crontab is already up to date (interval: $update_interval minutes)" + return 0 + fi + + info "Setting update interval to $update_interval minutes" + echo "$expected_crontab" | crontab - + success "Crontab updated successfully" + else + error "Could not determine update interval from server" + fi + else + error "Failed to get update interval policy" + fi +} + +# Show detailed system diagnostics +show_diagnostics() { + info "PatchMon Agent Diagnostics v$AGENT_VERSION" + echo "" + + # System information + echo "=== System Information ===" + echo "OS: $(uname -s)" + echo "Architecture: $(uname -m)" + echo "Kernel: $(uname -r)" + echo "Hostname: $(hostname)" + echo "Uptime: $(uptime -p 2>/dev/null || uptime)" + echo "" + + # Agent information + echo "=== Agent Information ===" + echo "Version: $AGENT_VERSION" + echo "Script Path: $0" + echo "Config File: $CONFIG_FILE" + echo "Credentials File: $CREDENTIALS_FILE" + echo "Log File: $LOG_FILE" + echo "Script Size: $(stat -c%s "$0" 2>/dev/null || echo "Unknown") bytes" + echo "Last Modified: $(stat -c%y "$0" 2>/dev/null || echo "Unknown")" + echo "" + + # Configuration + if [[ -f "$CONFIG_FILE" ]]; then + echo "=== Configuration ===" + cat "$CONFIG_FILE" + echo "" + else + echo "=== Configuration ===" + echo "No configuration file found at $CONFIG_FILE" + echo "" + fi + + # Credentials status + echo "=== Credentials Status ===" + if [[ -f "$CREDENTIALS_FILE" ]]; then + echo "Credentials file exists: Yes" + echo "File size: $(stat -c%s "$CREDENTIALS_FILE" 2>/dev/null || echo "Unknown") bytes" + echo "File permissions: $(stat -c%a "$CREDENTIALS_FILE" 2>/dev/null || echo "Unknown")" + else + echo "Credentials file exists: No" + fi + echo "" + + # Crontab status + echo "=== Crontab Status ===" + local crontab_entries=$(crontab -l 2>/dev/null | grep patchmon-agent || echo "None") + if [[ "$crontab_entries" != "None" ]]; then + echo "Crontab entries:" + echo "$crontab_entries" + else + echo "No crontab entries found" + fi + echo "" + + # Network connectivity + echo "=== Network Connectivity ===" + if ping -c 1 -W 3 "$(echo "$PATCHMON_SERVER" | sed 's|http://||' | sed 's|https://||' | cut -d: -f1)" >/dev/null 2>&1; then + echo "Server reachable: Yes" + else + echo "Server reachable: No" + fi + echo "Server URL: $PATCHMON_SERVER" + echo "" + + # Recent logs + echo "=== Recent Logs (last 10 lines) ===" + if [[ -f "$LOG_FILE" ]]; then + tail -10 "$LOG_FILE" 2>/dev/null || echo "Could not read log file" + else + echo "Log file does not exist" + fi +} + +# Show current configuration +show_config() { + info "Current Configuration:" + echo " Server: ${PATCHMON_SERVER}" + echo " API Version: ${API_VERSION}" + echo " Agent Version: ${AGENT_VERSION}" + echo " Config File: ${CONFIG_FILE}" + echo " Credentials File: ${CREDENTIALS_FILE}" + echo " Log File: ${LOG_FILE}" + + if [[ -f "$CREDENTIALS_FILE" ]]; then + source "$CREDENTIALS_FILE" + echo " API ID: ${API_ID}" + echo " API Key: ${API_KEY:0:8}..." # Show only first 8 characters + else + echo " API Credentials: Not configured" + fi +} + +# Main function +main() { + case "$1" in + "configure") + check_root + setup_directories + load_config + configure_credentials "$2" "$3" + ;; + "test") + check_root + setup_directories + load_config + test_credentials + ;; + "update") + check_root + setup_directories + load_config + detect_os + send_update + ;; + "ping") + check_root + setup_directories + load_config + ping_server + ;; + "config") + load_config + show_config + ;; + "check-version") + check_root + setup_directories + load_config + check_version + ;; + "update-agent") + check_root + setup_directories + load_config + update_agent + ;; + "update-crontab") + check_root + setup_directories + load_config + update_crontab + ;; + "diagnostics") + show_diagnostics + ;; + *) + echo "PatchMon Agent v$AGENT_VERSION - API Credential Based" + echo "Usage: $0 {configure|test|update|ping|config|check-version|update-agent|update-crontab|diagnostics}" + echo "" + echo "Commands:" + echo " configure - Configure API credentials for this host" + echo " test - Test API credentials connectivity" + echo " update - Send package update information to server" + echo " ping - Test connectivity to server" + echo " config - Show current configuration" + echo " check-version - Check for agent updates" + echo " update-agent - Update agent to latest version" + echo " update-crontab - Update crontab with current policy" + echo " diagnostics - Show detailed system diagnostics" + echo "" + echo "Setup Process:" + echo " 1. Contact your PatchMon administrator to create a host entry" + echo " 2. Run: $0 configure (provided by admin)" + echo " 3. Run: $0 test (to verify connection)" + echo " 4. Run: $0 update (to send initial package data)" + echo "" + echo "Configuration:" + echo " Edit $CONFIG_FILE to customize server settings" + echo " PATCHMON_SERVER=http://your-server:3001" + exit 1 + ;; + esac +} + +# Run main function +main "$@" \ No newline at end of file diff --git a/agents/patchmon_install.sh b/agents/patchmon_install.sh new file mode 100644 index 0000000..cb18375 --- /dev/null +++ b/agents/patchmon_install.sh @@ -0,0 +1,156 @@ +#!/bin/bash + +# PatchMon Agent Installation Script +# Usage: curl -sSL {PATCHMON_URL}/api/v1/hosts/install | bash -s -- {PATCHMON_URL} {API_ID} {API_KEY} + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Functions +error() { + echo -e "${RED}❌ ERROR: $1${NC}" >&2 + exit 1 +} + +info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +success() { + echo -e "${GREEN}✅ $1${NC}" +} + +warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +# Check if running as root +if [[ $EUID -ne 0 ]]; then + error "This script must be run as root (use sudo)" +fi + +# Default server URL (will be replaced by backend with configured URL) +PATCHMON_URL="http://localhost:3001" + +# Parse arguments +if [[ $# -ne 3 ]]; then + echo "Usage: curl -sSL {PATCHMON_URL}/api/v1/hosts/install | bash -s -- {PATCHMON_URL} {API_ID} {API_KEY}" + echo "" + echo "Example:" + echo "curl -sSL http://patchmon.example.com/api/v1/hosts/install | bash -s -- http://patchmon.example.com patchmon_1a2b3c4d abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + echo "" + echo "Contact your PatchMon administrator to get your API credentials." + exit 1 +fi + +PATCHMON_URL="$1" +API_ID="$2" +API_KEY="$3" + +# Validate inputs +if [[ ! "$PATCHMON_URL" =~ ^https?:// ]]; then + error "Invalid URL format. Must start with http:// or https://" +fi + +if [[ ! "$API_ID" =~ ^patchmon_[a-f0-9]{16}$ ]]; then + error "Invalid API ID format. API ID should be in format: patchmon_xxxxxxxxxxxxxxxx" +fi + +if [[ ! "$API_KEY" =~ ^[a-f0-9]{64}$ ]]; then + error "Invalid API Key format. API Key should be 64 hexadecimal characters." +fi + +info "🚀 Installing PatchMon Agent..." +info " Server: $PATCHMON_URL" +info " API ID: $API_ID" + +# Create patchmon directory +info "📁 Creating configuration directory..." +mkdir -p /etc/patchmon + +# Download the agent script +info "📥 Downloading PatchMon agent script..." +curl -sSL "$PATCHMON_URL/api/v1/hosts/agent/download" -o /usr/local/bin/patchmon-agent.sh +chmod +x /usr/local/bin/patchmon-agent.sh + +# Get the agent version from the downloaded script +AGENT_VERSION=$(grep '^AGENT_VERSION=' /usr/local/bin/patchmon-agent.sh | cut -d'"' -f2) +info "📋 Agent version: $AGENT_VERSION" + +# Get expected agent version from server +EXPECTED_VERSION=$(curl -s "$PATCHMON_URL/api/v1/hosts/agent/version" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4 2>/dev/null || echo "Unknown") +if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then + info "📋 Expected version: $EXPECTED_VERSION" + if [[ "$AGENT_VERSION" != "$EXPECTED_VERSION" ]]; then + warning "⚠️ Agent version mismatch! Installed: $AGENT_VERSION, Expected: $EXPECTED_VERSION" + fi +fi + +# Get update interval policy from server +UPDATE_INTERVAL=$(curl -s "$PATCHMON_URL/api/v1/settings/update-interval" | grep -o '"updateInterval":[0-9]*' | cut -d':' -f2 2>/dev/null || echo "60") +info "📋 Update interval: $UPDATE_INTERVAL minutes" + +# Create credentials file +info "🔐 Setting up API credentials..." +cat > /etc/patchmon/credentials << EOF +# PatchMon API Credentials +# Generated on $(date) +PATCHMON_URL="$PATCHMON_URL" +API_ID="$API_ID" +API_KEY="$API_KEY" +EOF + +chmod 600 /etc/patchmon/credentials + +# Test the configuration +info "🧪 Testing configuration..." +if /usr/local/bin/patchmon-agent.sh test; then + success "Configuration test passed!" +else + error "Configuration test failed. Please check your credentials." +fi + +# Send initial update +info "📊 Sending initial package data..." +if /usr/local/bin/patchmon-agent.sh update; then + success "Initial package data sent successfully!" +else + warning "Initial package data failed, but agent is configured. You can run 'patchmon-agent.sh update' manually." +fi + +# Setup crontab for automatic updates +info "⏰ Setting up automatic updates every $UPDATE_INTERVAL minutes..." +if [[ $UPDATE_INTERVAL -eq 60 ]]; then + # Hourly updates + echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | crontab - +else + # Custom interval updates + echo "*/$UPDATE_INTERVAL * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | crontab - +fi + +success "🎉 PatchMon Agent installation complete!" +echo "" +echo "📋 Installation Summary:" +echo " • Agent installed: /usr/local/bin/patchmon-agent.sh" +echo " • Agent version: $AGENT_VERSION" +if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then + echo " • Expected version: $EXPECTED_VERSION" +fi +echo " • Config directory: /etc/patchmon/" +echo " • Credentials file: /etc/patchmon/credentials" +echo " • Automatic updates: Every $UPDATE_INTERVAL minutes via crontab" +echo " • View logs: tail -f /var/log/patchmon-agent.sh" +echo "" +echo "🔧 Manual commands:" +echo " • Test connection: patchmon-agent.sh test" +echo " • Send update: patchmon-agent.sh update" +echo " • Check status: patchmon-agent.sh ping" +echo "" +success "Your host is now connected to PatchMon!" + diff --git a/backend/env.example b/backend/env.example new file mode 100644 index 0000000..6f37d61 --- /dev/null +++ b/backend/env.example @@ -0,0 +1,17 @@ +# Database Configuration +DATABASE_URL="postgresql://patchmon_user:p@tchm0n_p@55@localhost:5432/patchmon_db" + +# Server Configuration +PORT=3001 +NODE_ENV=development + +# API Configuration +API_VERSION=v1 +CORS_ORIGIN=http://localhost:3000 + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=100 + +# Logging +LOG_LEVEL=info diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..1433daf --- /dev/null +++ b/backend/package.json @@ -0,0 +1,36 @@ +{ + "name": "patchmon-backend", + "version": "1.0.0", + "description": "Backend API for Linux Patch Monitoring System", + "main": "src/server.js", + "scripts": { + "dev": "nodemon src/server.js", + "start": "node src/server.js", + "build": "echo 'No build step needed for Node.js'", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:push": "prisma db push", + "db:studio": "prisma studio" + }, + "dependencies": { + "@prisma/client": "^5.7.0", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "express-validator": "^7.0.1", + "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "moment": "^2.30.1", + "uuid": "^9.0.1", + "winston": "^3.11.0" + }, + "devDependencies": { + "nodemon": "^3.0.2", + "prisma": "^5.7.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/backend/prisma/migrations/20250716185346_init/migration.sql b/backend/prisma/migrations/20250716185346_init/migration.sql new file mode 100644 index 0000000..4350de6 --- /dev/null +++ b/backend/prisma/migrations/20250716185346_init/migration.sql @@ -0,0 +1,77 @@ +-- CreateTable +CREATE TABLE "hosts" ( + "id" TEXT NOT NULL, + "hostname" TEXT NOT NULL, + "ip" TEXT, + "os_type" TEXT NOT NULL, + "os_version" TEXT NOT NULL, + "architecture" TEXT, + "last_update" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" TEXT NOT NULL DEFAULT 'active', + "token" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "hosts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "packages" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "category" TEXT, + "latest_version" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "packages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "host_packages" ( + "id" TEXT NOT NULL, + "host_id" TEXT NOT NULL, + "package_id" TEXT NOT NULL, + "current_version" TEXT NOT NULL, + "available_version" TEXT, + "needs_update" BOOLEAN NOT NULL DEFAULT false, + "is_security_update" BOOLEAN NOT NULL DEFAULT false, + "last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "host_packages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "update_history" ( + "id" TEXT NOT NULL, + "host_id" TEXT NOT NULL, + "packages_count" INTEGER NOT NULL, + "security_count" INTEGER NOT NULL, + "timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" TEXT NOT NULL DEFAULT 'success', + "error_message" TEXT, + + CONSTRAINT "update_history_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "hosts_hostname_key" ON "hosts"("hostname"); + +-- CreateIndex +CREATE UNIQUE INDEX "hosts_token_key" ON "hosts"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "packages_name_key" ON "packages"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "host_packages_host_id_package_id_key" ON "host_packages"("host_id", "package_id"); + +-- AddForeignKey +ALTER TABLE "host_packages" ADD CONSTRAINT "host_packages_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "host_packages" ADD CONSTRAINT "host_packages_package_id_fkey" FOREIGN KEY ("package_id") REFERENCES "packages"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "update_history" ADD CONSTRAINT "update_history_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20250915171333_add_role_permissions/migration.sql b/backend/prisma/migrations/20250915171333_add_role_permissions/migration.sql new file mode 100644 index 0000000..bcd695a --- /dev/null +++ b/backend/prisma/migrations/20250915171333_add_role_permissions/migration.sql @@ -0,0 +1,67 @@ +/* + Warnings: + + - You are about to drop the column `token` on the `hosts` table. All the data in the column will be lost. + - A unique constraint covering the columns `[api_id]` on the table `hosts` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[api_key]` on the table `hosts` will be added. If there are existing duplicate values, this will fail. + - Added the required column `api_id` to the `hosts` table without a default value. This is not possible if the table is not empty. + - Added the required column `api_key` to the `hosts` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropIndex +DROP INDEX "hosts_token_key"; + +-- AlterTable +ALTER TABLE "hosts" DROP COLUMN "token", +ADD COLUMN "api_id" TEXT NOT NULL, +ADD COLUMN "api_key" TEXT NOT NULL; + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password_hash" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'admin', + "is_active" BOOLEAN NOT NULL DEFAULT true, + "last_login" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "role_permissions" ( + "id" TEXT NOT NULL, + "role" TEXT NOT NULL, + "can_view_dashboard" BOOLEAN NOT NULL DEFAULT true, + "can_view_hosts" BOOLEAN NOT NULL DEFAULT true, + "can_manage_hosts" BOOLEAN NOT NULL DEFAULT false, + "can_view_packages" BOOLEAN NOT NULL DEFAULT true, + "can_manage_packages" BOOLEAN NOT NULL DEFAULT false, + "can_view_users" BOOLEAN NOT NULL DEFAULT false, + "can_manage_users" BOOLEAN NOT NULL DEFAULT false, + "can_view_reports" BOOLEAN NOT NULL DEFAULT true, + "can_export_data" BOOLEAN NOT NULL DEFAULT false, + "can_manage_settings" BOOLEAN NOT NULL DEFAULT false, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "role_permissions_role_key" ON "role_permissions"("role"); + +-- CreateIndex +CREATE UNIQUE INDEX "hosts_api_id_key" ON "hosts"("api_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "hosts_api_key_key" ON "hosts"("api_key"); diff --git a/backend/prisma/migrations/20250915190113_add_settings_table/migration.sql b/backend/prisma/migrations/20250915190113_add_settings_table/migration.sql new file mode 100644 index 0000000..1038fb1 --- /dev/null +++ b/backend/prisma/migrations/20250915190113_add_settings_table/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "settings" ( + "id" TEXT NOT NULL, + "server_url" TEXT NOT NULL DEFAULT 'http://localhost:3001', + "server_protocol" TEXT NOT NULL DEFAULT 'http', + "server_host" TEXT NOT NULL DEFAULT 'localhost', + "server_port" INTEGER NOT NULL DEFAULT 3001, + "frontend_url" TEXT NOT NULL DEFAULT 'http://localhost:3000', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "settings_pkey" PRIMARY KEY ("id") +); diff --git a/backend/prisma/migrations/20250915200316_add_dashboard_preferences/migration.sql b/backend/prisma/migrations/20250915200316_add_dashboard_preferences/migration.sql new file mode 100644 index 0000000..8fe0634 --- /dev/null +++ b/backend/prisma/migrations/20250915200316_add_dashboard_preferences/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "dashboard_preferences" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "card_id" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "dashboard_preferences_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "dashboard_preferences_user_id_card_id_key" ON "dashboard_preferences"("user_id", "card_id"); + +-- AddForeignKey +ALTER TABLE "dashboard_preferences" ADD CONSTRAINT "dashboard_preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20250915203810_add_host_groups/migration.sql b/backend/prisma/migrations/20250915203810_add_host_groups/migration.sql new file mode 100644 index 0000000..5976f6c --- /dev/null +++ b/backend/prisma/migrations/20250915203810_add_host_groups/migration.sql @@ -0,0 +1,20 @@ +-- AlterTable +ALTER TABLE "hosts" ADD COLUMN "host_group_id" TEXT; + +-- CreateTable +CREATE TABLE "host_groups" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "color" TEXT DEFAULT '#3B82F6', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "host_groups_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "host_groups_name_key" ON "host_groups"("name"); + +-- AddForeignKey +ALTER TABLE "hosts" ADD CONSTRAINT "hosts_host_group_id_fkey" FOREIGN KEY ("host_group_id") REFERENCES "host_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20250915235823_add_agent_versioning/migration.sql b/backend/prisma/migrations/20250915235823_add_agent_versioning/migration.sql new file mode 100644 index 0000000..5584e88 --- /dev/null +++ b/backend/prisma/migrations/20250915235823_add_agent_versioning/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE "hosts" ADD COLUMN "agent_version" TEXT; + +-- CreateTable +CREATE TABLE "agent_versions" ( + "id" TEXT NOT NULL, + "version" TEXT NOT NULL, + "is_current" BOOLEAN NOT NULL DEFAULT false, + "release_notes" TEXT, + "download_url" TEXT, + "min_server_version" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "agent_versions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "agent_versions_version_key" ON "agent_versions"("version"); diff --git a/backend/prisma/migrations/20250916001500_add_agent_script_content/migration.sql b/backend/prisma/migrations/20250916001500_add_agent_script_content/migration.sql new file mode 100644 index 0000000..93a62e7 --- /dev/null +++ b/backend/prisma/migrations/20250916001500_add_agent_script_content/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "agent_versions" ADD COLUMN "is_default" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "script_content" TEXT; diff --git a/backend/prisma/migrations/20250916004317_add_update_interval_setting/migration.sql b/backend/prisma/migrations/20250916004317_add_update_interval_setting/migration.sql new file mode 100644 index 0000000..99c0aad --- /dev/null +++ b/backend/prisma/migrations/20250916004317_add_update_interval_setting/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "settings" ADD COLUMN "update_interval" INTEGER NOT NULL DEFAULT 60; diff --git a/backend/prisma/migrations/20250916011838_add_auto_update_setting/migration.sql b/backend/prisma/migrations/20250916011838_add_auto_update_setting/migration.sql new file mode 100644 index 0000000..bbeb20d --- /dev/null +++ b/backend/prisma/migrations/20250916011838_add_auto_update_setting/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "settings" ADD COLUMN "auto_update" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/migrations/20250916012912_add_host_auto_update/migration.sql b/backend/prisma/migrations/20250916012912_add_host_auto_update/migration.sql new file mode 100644 index 0000000..e8ae986 --- /dev/null +++ b/backend/prisma/migrations/20250916012912_add_host_auto_update/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "hosts" ADD COLUMN "auto_update" BOOLEAN NOT NULL DEFAULT true; diff --git a/backend/prisma/migrations/20250916040214_add_repositories_module/migration.sql b/backend/prisma/migrations/20250916040214_add_repositories_module/migration.sql new file mode 100644 index 0000000..f46a75c --- /dev/null +++ b/backend/prisma/migrations/20250916040214_add_repositories_module/migration.sql @@ -0,0 +1,40 @@ +-- CreateTable +CREATE TABLE "repositories" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "url" TEXT NOT NULL, + "distribution" TEXT NOT NULL, + "components" TEXT NOT NULL, + "repo_type" TEXT NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "is_secure" BOOLEAN NOT NULL DEFAULT true, + "priority" INTEGER, + "description" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "repositories_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "host_repositories" ( + "id" TEXT NOT NULL, + "host_id" TEXT NOT NULL, + "repository_id" TEXT NOT NULL, + "is_enabled" BOOLEAN NOT NULL DEFAULT true, + "last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "host_repositories_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "repositories_url_distribution_components_key" ON "repositories"("url", "distribution", "components"); + +-- CreateIndex +CREATE UNIQUE INDEX "host_repositories_host_id_repository_id_key" ON "host_repositories"("host_id", "repository_id"); + +-- AddForeignKey +ALTER TABLE "host_repositories" ADD CONSTRAINT "host_repositories_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "host_repositories" ADD CONSTRAINT "host_repositories_repository_id_fkey" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..7503af7 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,217 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(cuid()) + username String @unique + email String @unique + passwordHash String @map("password_hash") + role String @default("admin") // admin, user + isActive Boolean @default(true) @map("is_active") + lastLogin DateTime? @map("last_login") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @updatedAt + + // Relationships + dashboardPreferences DashboardPreferences[] + + @@map("users") +} + +model RolePermissions { + id String @id @default(cuid()) + role String @unique // admin, user, custom roles + canViewDashboard Boolean @default(true) @map("can_view_dashboard") + canViewHosts Boolean @default(true) @map("can_view_hosts") + canManageHosts Boolean @default(false) @map("can_manage_hosts") + canViewPackages Boolean @default(true) @map("can_view_packages") + canManagePackages Boolean @default(false) @map("can_manage_packages") + canViewUsers Boolean @default(false) @map("can_view_users") + canManageUsers Boolean @default(false) @map("can_manage_users") + canViewReports Boolean @default(true) @map("can_view_reports") + canExportData Boolean @default(false) @map("can_export_data") + canManageSettings Boolean @default(false) @map("can_manage_settings") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @updatedAt + + @@map("role_permissions") +} + +model HostGroup { + id String @id @default(cuid()) + name String @unique + description String? + color String? @default("#3B82F6") // Hex color for UI display + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @updatedAt + + // Relationships + hosts Host[] + + @@map("host_groups") +} + +model Host { + id String @id @default(cuid()) + hostname String @unique + ip String? + osType String @map("os_type") + osVersion String @map("os_version") + architecture String? + lastUpdate DateTime @map("last_update") @default(now()) + status String @default("active") // active, inactive, error + apiId String @unique @map("api_id") // New API ID for authentication + apiKey String @unique @map("api_key") // New API Key for authentication + hostGroupId String? @map("host_group_id") // Optional group association + agentVersion String? @map("agent_version") // Agent script version + autoUpdate Boolean @map("auto_update") @default(true) // Enable auto-update for this host + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @updatedAt + + // Relationships + hostPackages HostPackage[] + updateHistory UpdateHistory[] + hostRepositories HostRepository[] + hostGroup HostGroup? @relation(fields: [hostGroupId], references: [id], onDelete: SetNull) + + @@map("hosts") +} + +model Package { + id String @id @default(cuid()) + name String @unique + description String? + category String? // system, security, development, etc. + latestVersion String? @map("latest_version") + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @updatedAt + + // Relationships + hostPackages HostPackage[] + + @@map("packages") +} + +model HostPackage { + id String @id @default(cuid()) + hostId String @map("host_id") + packageId String @map("package_id") + currentVersion String @map("current_version") + availableVersion String? @map("available_version") + needsUpdate Boolean @map("needs_update") @default(false) + isSecurityUpdate Boolean @map("is_security_update") @default(false) + lastChecked DateTime @map("last_checked") @default(now()) + + // Relationships + host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) + package Package @relation(fields: [packageId], references: [id], onDelete: Cascade) + + @@unique([hostId, packageId]) + @@map("host_packages") +} + +model UpdateHistory { + id String @id @default(cuid()) + hostId String @map("host_id") + packagesCount Int @map("packages_count") + securityCount Int @map("security_count") + timestamp DateTime @default(now()) + status String @default("success") // success, error + errorMessage String? @map("error_message") + + // Relationships + host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) + + @@map("update_history") +} + +model Repository { + id String @id @default(cuid()) + name String // Repository name (e.g., "focal", "focal-updates") + url String // Repository URL + distribution String // Distribution (e.g., "focal", "jammy") + components String // Components (e.g., "main restricted universe multiverse") + repoType String @map("repo_type") // "deb" or "deb-src" + isActive Boolean @map("is_active") @default(true) + isSecure Boolean @map("is_secure") @default(true) // HTTPS vs HTTP + priority Int? // Repository priority + description String? // Optional description + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @updatedAt + + // Relationships + hostRepositories HostRepository[] + + @@unique([url, distribution, components]) + @@map("repositories") +} + +model HostRepository { + id String @id @default(cuid()) + hostId String @map("host_id") + repositoryId String @map("repository_id") + isEnabled Boolean @map("is_enabled") @default(true) + lastChecked DateTime @map("last_checked") @default(now()) + + // Relationships + host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) + repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade) + + @@unique([hostId, repositoryId]) + @@map("host_repositories") +} + +model Settings { + id String @id @default(cuid()) + serverUrl String @map("server_url") @default("http://localhost:3001") + serverProtocol String @map("server_protocol") @default("http") // http, https + serverHost String @map("server_host") @default("localhost") + serverPort Int @map("server_port") @default(3001) + frontendUrl String @map("frontend_url") @default("http://localhost:3000") + updateInterval Int @map("update_interval") @default(60) // Update interval in minutes + autoUpdate Boolean @map("auto_update") @default(false) // Enable automatic agent updates + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @updatedAt + + @@map("settings") +} + +model DashboardPreferences { + id String @id @default(cuid()) + userId String @map("user_id") + cardId String @map("card_id") // e.g., "totalHosts", "securityUpdates", etc. + enabled Boolean @default(true) + order Int @default(0) + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @updatedAt + + // Relationships + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, cardId]) + @@map("dashboard_preferences") +} + +model AgentVersion { + id String @id @default(cuid()) + version String @unique // e.g., "1.0.0", "1.1.0" + isCurrent Boolean @default(false) @map("is_current") // Only one version can be current + releaseNotes String? @map("release_notes") + downloadUrl String? @map("download_url") // URL to download the agent script + minServerVersion String? @map("min_server_version") // Minimum server version required + scriptContent String? @map("script_content") // The actual agent script content + isDefault Boolean @default(false) @map("is_default") // Default version for new installations + createdAt DateTime @map("created_at") @default(now()) + updatedAt DateTime @map("updated_at") @updatedAt + + @@map("agent_versions") +} \ No newline at end of file diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..b62d1bb --- /dev/null +++ b/backend/src/middleware/auth.js @@ -0,0 +1,98 @@ +const jwt = require('jsonwebtoken'); +const { PrismaClient } = require('@prisma/client'); + +const prisma = new PrismaClient(); + +// Middleware to verify JWT token +const authenticateToken = async (req, res, next) => { + try { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + + if (!token) { + return res.status(401).json({ error: 'Access token required' }); + } + + // Verify token + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key'); + + // Get user from database + const user = await prisma.user.findUnique({ + where: { id: decoded.userId }, + select: { + id: true, + username: true, + email: true, + role: true, + isActive: true, + lastLogin: true + } + }); + + if (!user || !user.isActive) { + return res.status(401).json({ error: 'Invalid or inactive user' }); + } + + // Update last login + await prisma.user.update({ + where: { id: user.id }, + data: { lastLogin: new Date() } + }); + + req.user = user; + next(); + } catch (error) { + if (error.name === 'JsonWebTokenError') { + return res.status(401).json({ error: 'Invalid token' }); + } + if (error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expired' }); + } + console.error('Auth middleware error:', error); + return res.status(500).json({ error: 'Authentication failed' }); + } +}; + +// Middleware to check admin role +const requireAdmin = (req, res, next) => { + if (req.user.role !== 'admin') { + return res.status(403).json({ error: 'Admin access required' }); + } + next(); +}; + +// Middleware to check if user is authenticated (optional) +const optionalAuth = async (req, res, next) => { + try { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (token) { + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key'); + const user = await prisma.user.findUnique({ + where: { id: decoded.userId }, + select: { + id: true, + username: true, + email: true, + role: true, + isActive: true + } + }); + + if (user && user.isActive) { + req.user = user; + } + } + next(); + } catch (error) { + // Continue without authentication for optional auth + next(); + } +}; + +module.exports = { + authenticateToken, + requireAdmin, + optionalAuth +}; diff --git a/backend/src/middleware/permissions.js b/backend/src/middleware/permissions.js new file mode 100644 index 0000000..378494a --- /dev/null +++ b/backend/src/middleware/permissions.js @@ -0,0 +1,59 @@ +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +// Permission middleware factory +const requirePermission = (permission) => { + return async (req, res, next) => { + try { + // Get user's role permissions + const rolePermissions = await prisma.rolePermissions.findUnique({ + where: { role: req.user.role } + }); + + // If no specific permissions found, default to admin permissions (for backward compatibility) + if (!rolePermissions) { + console.warn(`No permissions found for role: ${req.user.role}, defaulting to admin access`); + return next(); + } + + // Check if user has the required permission + if (!rolePermissions[permission]) { + return res.status(403).json({ + error: 'Insufficient permissions', + message: `You don't have permission to ${permission.replace('can', '').toLowerCase()}` + }); + } + + next(); + } catch (error) { + console.error('Permission check error:', error); + res.status(500).json({ error: 'Permission check failed' }); + } + }; +}; + +// Specific permission middlewares +const requireViewDashboard = requirePermission('canViewDashboard'); +const requireViewHosts = requirePermission('canViewHosts'); +const requireManageHosts = requirePermission('canManageHosts'); +const requireViewPackages = requirePermission('canViewPackages'); +const requireManagePackages = requirePermission('canManagePackages'); +const requireViewUsers = requirePermission('canViewUsers'); +const requireManageUsers = requirePermission('canManageUsers'); +const requireViewReports = requirePermission('canViewReports'); +const requireExportData = requirePermission('canExportData'); +const requireManageSettings = requirePermission('canManageSettings'); + +module.exports = { + requirePermission, + requireViewDashboard, + requireViewHosts, + requireManageHosts, + requireViewPackages, + requireManagePackages, + requireViewUsers, + requireManageUsers, + requireViewReports, + requireExportData, + requireManageSettings +}; diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js new file mode 100644 index 0000000..32db367 --- /dev/null +++ b/backend/src/routes/authRoutes.js @@ -0,0 +1,452 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const { PrismaClient } = require('@prisma/client'); +const { body, validationResult } = require('express-validator'); +const { authenticateToken, requireAdmin } = require('../middleware/auth'); +const { requireViewUsers, requireManageUsers } = require('../middleware/permissions'); + +const router = express.Router(); +const prisma = new PrismaClient(); + +// Generate JWT token +const generateToken = (userId) => { + return jwt.sign( + { userId }, + process.env.JWT_SECRET || 'your-secret-key', + { expiresIn: process.env.JWT_EXPIRES_IN || '24h' } + ); +}; + +// Admin endpoint to list all users +router.get('/admin/users', authenticateToken, requireViewUsers, async (req, res) => { + try { + const users = await prisma.user.findMany({ + select: { + id: true, + username: true, + email: true, + role: true, + isActive: true, + lastLogin: true, + createdAt: true, + updatedAt: true + }, + orderBy: { + createdAt: 'desc' + } + }) + + res.json(users) + } catch (error) { + console.error('List users error:', error) + res.status(500).json({ error: 'Failed to fetch users' }) + } +}) + +// Admin endpoint to create a new user +router.post('/admin/users', authenticateToken, requireManageUsers, [ + body('username').isLength({ min: 3 }).withMessage('Username must be at least 3 characters'), + body('email').isEmail().withMessage('Valid email is required'), + body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'), + body('role').optional().custom(async (value) => { + if (!value) return true; // Optional field + const rolePermissions = await prisma.rolePermissions.findUnique({ + where: { role: value } + }); + if (!rolePermissions) { + throw new Error('Invalid role specified'); + } + return true; + }) +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { username, email, password, role = 'user' } = req.body; + + // Check if user already exists + const existingUser = await prisma.user.findFirst({ + where: { + OR: [ + { username }, + { email } + ] + } + }); + + if (existingUser) { + return res.status(409).json({ error: 'Username or email already exists' }); + } + + // Hash password + const passwordHash = await bcrypt.hash(password, 12); + + // Create user + const user = await prisma.user.create({ + data: { + username, + email, + passwordHash, + role + }, + select: { + id: true, + username: true, + email: true, + role: true, + isActive: true, + createdAt: true + } + }); + + res.status(201).json({ + message: 'User created successfully', + user + }); + } catch (error) { + console.error('User creation error:', error); + res.status(500).json({ error: 'Failed to create user' }); + } +}); + +// Admin endpoint to update a user +router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [ + body('username').optional().isLength({ min: 3 }).withMessage('Username must be at least 3 characters'), + body('email').optional().isEmail().withMessage('Valid email is required'), + body('role').optional().custom(async (value) => { + if (!value) return true; // Optional field + const rolePermissions = await prisma.rolePermissions.findUnique({ + where: { role: value } + }); + if (!rolePermissions) { + throw new Error('Invalid role specified'); + } + return true; + }), + body('isActive').optional().isBoolean().withMessage('isActive must be a boolean') +], async (req, res) => { + try { + const { userId } = req.params; + const errors = validationResult(req); + + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { username, email, role, isActive } = req.body; + const updateData = {}; + + if (username) updateData.username = username; + if (email) updateData.email = email; + if (role) updateData.role = role; + if (typeof isActive === 'boolean') updateData.isActive = isActive; + + // Check if user exists + const existingUser = await prisma.user.findUnique({ + where: { id: userId } + }); + + if (!existingUser) { + return res.status(404).json({ error: 'User not found' }); + } + + // Check if username/email already exists (excluding current user) + if (username || email) { + const duplicateUser = await prisma.user.findFirst({ + where: { + AND: [ + { id: { not: userId } }, + { + OR: [ + ...(username ? [{ username }] : []), + ...(email ? [{ email }] : []) + ] + } + ] + } + }); + + if (duplicateUser) { + return res.status(409).json({ error: 'Username or email already exists' }); + } + } + + // Prevent deactivating the last admin + if (isActive === false && existingUser.role === 'admin') { + const adminCount = await prisma.user.count({ + where: { + role: 'admin', + isActive: true + } + }); + + if (adminCount <= 1) { + return res.status(400).json({ error: 'Cannot deactivate the last admin user' }); + } + } + + // Update user + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: updateData, + select: { + id: true, + username: true, + email: true, + role: true, + isActive: true, + lastLogin: true, + createdAt: true, + updatedAt: true + } + }); + + res.json({ + message: 'User updated successfully', + user: updatedUser + }); + } catch (error) { + console.error('User update error:', error); + res.status(500).json({ error: 'Failed to update user' }); + } +}); + +// Admin endpoint to delete a user +router.delete('/admin/users/:userId', authenticateToken, requireManageUsers, async (req, res) => { + try { + const { userId } = req.params; + + // Prevent self-deletion + if (userId === req.user.id) { + return res.status(400).json({ error: 'Cannot delete your own account' }); + } + + // Check if user exists + const user = await prisma.user.findUnique({ + where: { id: userId } + }); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + // Prevent deleting the last admin + if (user.role === 'admin') { + const adminCount = await prisma.user.count({ + where: { + role: 'admin', + isActive: true + } + }); + + if (adminCount <= 1) { + return res.status(400).json({ error: 'Cannot delete the last admin user' }); + } + } + + // Delete user + await prisma.user.delete({ + where: { id: userId } + }); + + res.json({ + message: 'User deleted successfully' + }); + } catch (error) { + console.error('User deletion error:', error); + res.status(500).json({ error: 'Failed to delete user' }); + } +}); + +// Login +router.post('/login', [ + body('username').notEmpty().withMessage('Username is required'), + body('password').notEmpty().withMessage('Password is required') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { username, password } = req.body; + + // Find user by username or email + const user = await prisma.user.findFirst({ + where: { + OR: [ + { username }, + { email: username } + ], + isActive: true + } + }); + + if (!user) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + // Verify password + const isValidPassword = await bcrypt.compare(password, user.passwordHash); + if (!isValidPassword) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + // Update last login + await prisma.user.update({ + where: { id: user.id }, + data: { lastLogin: new Date() } + }); + + // Generate token + const token = generateToken(user.id); + + res.json({ + message: 'Login successful', + token, + user: { + id: user.id, + username: user.username, + email: user.email, + role: user.role + } + }); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ error: 'Login failed' }); + } +}); + +// Get current user profile +router.get('/profile', authenticateToken, async (req, res) => { + try { + res.json({ + user: req.user + }); + } catch (error) { + console.error('Get profile error:', error); + res.status(500).json({ error: 'Failed to get profile' }); + } +}); + +// Update user profile +router.put('/profile', authenticateToken, [ + body('username').optional().isLength({ min: 3 }).withMessage('Username must be at least 3 characters'), + body('email').optional().isEmail().withMessage('Valid email is required') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { username, email } = req.body; + const updateData = {}; + + if (username) updateData.username = username; + if (email) updateData.email = email; + + // Check if username/email already exists (excluding current user) + if (username || email) { + const existingUser = await prisma.user.findFirst({ + where: { + AND: [ + { id: { not: req.user.id } }, + { + OR: [ + ...(username ? [{ username }] : []), + ...(email ? [{ email }] : []) + ] + } + ] + } + }); + + if (existingUser) { + return res.status(409).json({ error: 'Username or email already exists' }); + } + } + + const updatedUser = await prisma.user.update({ + where: { id: req.user.id }, + data: updateData, + select: { + id: true, + username: true, + email: true, + role: true, + isActive: true, + lastLogin: true, + updatedAt: true + } + }); + + res.json({ + message: 'Profile updated successfully', + user: updatedUser + }); + } catch (error) { + console.error('Update profile error:', error); + res.status(500).json({ error: 'Failed to update profile' }); + } +}); + +// Change password +router.put('/change-password', authenticateToken, [ + body('currentPassword').notEmpty().withMessage('Current password is required'), + body('newPassword').isLength({ min: 6 }).withMessage('New password must be at least 6 characters') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { currentPassword, newPassword } = req.body; + + // Get user with password hash + const user = await prisma.user.findUnique({ + where: { id: req.user.id } + }); + + // Verify current password + const isValidPassword = await bcrypt.compare(currentPassword, user.passwordHash); + if (!isValidPassword) { + return res.status(401).json({ error: 'Current password is incorrect' }); + } + + // Hash new password + const newPasswordHash = await bcrypt.hash(newPassword, 12); + + // Update password + await prisma.user.update({ + where: { id: req.user.id }, + data: { passwordHash: newPasswordHash } + }); + + res.json({ + message: 'Password changed successfully' + }); + } catch (error) { + console.error('Change password error:', error); + res.status(500).json({ error: 'Failed to change password' }); + } +}); + +// Logout (client-side token removal) +router.post('/logout', authenticateToken, async (req, res) => { + try { + res.json({ + message: 'Logout successful' + }); + } catch (error) { + console.error('Logout error:', error); + res.status(500).json({ error: 'Logout failed' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/dashboardPreferencesRoutes.js b/backend/src/routes/dashboardPreferencesRoutes.js new file mode 100644 index 0000000..2d98d96 --- /dev/null +++ b/backend/src/routes/dashboardPreferencesRoutes.js @@ -0,0 +1,89 @@ +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const { PrismaClient } = require('@prisma/client'); +const { authenticateToken } = require('../middleware/auth'); + +const router = express.Router(); +const prisma = new PrismaClient(); + +// Get user's dashboard preferences +router.get('/', authenticateToken, async (req, res) => { + try { + const preferences = await prisma.dashboardPreferences.findMany({ + where: { userId: req.user.id }, + orderBy: { order: 'asc' } + }); + + res.json(preferences); + } catch (error) { + console.error('Dashboard preferences fetch error:', error); + res.status(500).json({ error: 'Failed to fetch dashboard preferences' }); + } +}); + +// Update dashboard preferences (bulk update) +router.put('/', authenticateToken, [ + body('preferences').isArray().withMessage('Preferences must be an array'), + body('preferences.*.cardId').isString().withMessage('Card ID is required'), + body('preferences.*.enabled').isBoolean().withMessage('Enabled must be boolean'), + body('preferences.*.order').isInt().withMessage('Order must be integer') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { preferences } = req.body; + const userId = req.user.id; + + // Delete existing preferences for this user + await prisma.dashboardPreferences.deleteMany({ + where: { userId } + }); + + // Create new preferences + const newPreferences = preferences.map(pref => ({ + userId, + cardId: pref.cardId, + enabled: pref.enabled, + order: pref.order + })); + + const createdPreferences = await prisma.dashboardPreferences.createMany({ + data: newPreferences + }); + + res.json({ + message: 'Dashboard preferences updated successfully', + preferences: newPreferences + }); + } catch (error) { + console.error('Dashboard preferences update error:', error); + res.status(500).json({ error: 'Failed to update dashboard preferences' }); + } +}); + +// Get default dashboard card configuration +router.get('/defaults', authenticateToken, async (req, res) => { + try { + const defaultCards = [ + { cardId: 'totalHosts', title: 'Total Hosts', icon: 'Server', enabled: true, order: 0 }, + { cardId: 'hostsNeedingUpdates', title: 'Needs Updating', icon: 'AlertTriangle', enabled: true, order: 1 }, + { cardId: 'totalOutdatedPackages', title: 'Outdated Packages', icon: 'Package', enabled: true, order: 2 }, + { cardId: 'securityUpdates', title: 'Security Updates', icon: 'Shield', enabled: true, order: 3 }, + { cardId: 'erroredHosts', title: 'Errored Hosts', icon: 'AlertTriangle', enabled: true, order: 4 }, + { cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 5 }, + { cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 6 }, + { cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 7 }, + { cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 8 } + ]; + + res.json(defaultCards); + } catch (error) { + console.error('Default dashboard cards error:', error); + res.status(500).json({ error: 'Failed to fetch default dashboard cards' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/dashboardRoutes.js b/backend/src/routes/dashboardRoutes.js new file mode 100644 index 0000000..c5483fb --- /dev/null +++ b/backend/src/routes/dashboardRoutes.js @@ -0,0 +1,313 @@ +const express = require('express'); +const { PrismaClient } = require('@prisma/client'); +const moment = require('moment'); +const { authenticateToken } = require('../middleware/auth'); +const { + requireViewDashboard, + requireViewHosts, + requireViewPackages +} = require('../middleware/permissions'); + +const router = express.Router(); +const prisma = new PrismaClient(); + +// Get dashboard statistics +router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) => { + try { + const now = new Date(); + const twentyFourHoursAgo = moment(now).subtract(24, 'hours').toDate(); + + // Get all statistics in parallel for better performance + const [ + totalHosts, + hostsNeedingUpdates, + totalOutdatedPackages, + erroredHosts, + securityUpdates, + osDistribution, + updateTrends + ] = await Promise.all([ + // Total hosts count + prisma.host.count({ + where: { status: 'active' } + }), + + // Hosts needing updates (distinct hosts with packages needing updates) + prisma.host.count({ + where: { + status: 'active', + hostPackages: { + some: { + needsUpdate: true + } + } + } + }), + + // Total outdated packages across all hosts + prisma.hostPackage.count({ + where: { needsUpdate: true } + }), + + // Errored hosts (not updated in 24 hours) + prisma.host.count({ + where: { + status: 'active', + lastUpdate: { + lt: twentyFourHoursAgo + } + } + }), + + // Security updates count + prisma.hostPackage.count({ + where: { + needsUpdate: true, + isSecurityUpdate: true + } + }), + + // OS distribution for pie chart + prisma.host.groupBy({ + by: ['osType'], + where: { status: 'active' }, + _count: { + osType: true + } + }), + + // Update trends for the last 7 days + prisma.updateHistory.groupBy({ + by: ['timestamp'], + where: { + timestamp: { + gte: moment(now).subtract(7, 'days').toDate() + } + }, + _count: { + id: true + }, + _sum: { + packagesCount: true, + securityCount: true + } + }) + ]); + + // Format OS distribution for pie chart + const osDistributionFormatted = osDistribution.map(item => ({ + name: item.osType, + count: item._count.osType + })); + + // Calculate update status distribution + const updateStatusDistribution = [ + { name: 'Up to date', count: totalHosts - hostsNeedingUpdates }, + { name: 'Needs updates', count: hostsNeedingUpdates }, + { name: 'Errored', count: erroredHosts } + ]; + + // Package update priority distribution + const packageUpdateDistribution = [ + { name: 'Security', count: securityUpdates }, + { name: 'Regular', count: totalOutdatedPackages - securityUpdates } + ]; + + res.json({ + cards: { + totalHosts, + hostsNeedingUpdates, + totalOutdatedPackages, + erroredHosts, + securityUpdates + }, + charts: { + osDistribution: osDistributionFormatted, + updateStatusDistribution, + packageUpdateDistribution + }, + trends: updateTrends, + lastUpdated: now.toISOString() + }); + } catch (error) { + console.error('Error fetching dashboard stats:', error); + res.status(500).json({ error: 'Failed to fetch dashboard statistics' }); + } +}); + +// Get hosts with their update status +router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => { + try { + const hosts = await prisma.host.findMany({ + // Show all hosts regardless of status + select: { + id: true, + hostname: true, + ip: true, + osType: true, + osVersion: true, + lastUpdate: true, + status: true, + agentVersion: true, + autoUpdate: true, + hostGroup: { + select: { + id: true, + name: true, + color: true + } + }, + _count: { + select: { + hostPackages: { + where: { + needsUpdate: true + } + } + } + } + }, + orderBy: { lastUpdate: 'desc' } + }); + + // Get update counts for each host separately + const hostsWithUpdateInfo = await Promise.all( + hosts.map(async (host) => { + const updatesCount = await prisma.hostPackage.count({ + where: { + hostId: host.id, + needsUpdate: true + } + }); + + return { + ...host, + updatesCount, + isStale: moment(host.lastUpdate).isBefore(moment().subtract(24, 'hours')) + }; + }) + ); + + res.json(hostsWithUpdateInfo); + } catch (error) { + console.error('Error fetching hosts:', error); + res.status(500).json({ error: 'Failed to fetch hosts' }); + } +}); + +// Get packages that need updates across all hosts +router.get('/packages', authenticateToken, requireViewPackages, async (req, res) => { + try { + const packages = await prisma.package.findMany({ + where: { + hostPackages: { + some: { + needsUpdate: true + } + } + }, + select: { + id: true, + name: true, + description: true, + category: true, + latestVersion: true, + hostPackages: { + where: { needsUpdate: true }, + select: { + currentVersion: true, + availableVersion: true, + isSecurityUpdate: true, + host: { + select: { + id: true, + hostname: true, + osType: true + } + } + } + } + }, + orderBy: { + name: 'asc' + } + }); + + const packagesWithHostInfo = packages.map(pkg => ({ + id: pkg.id, + name: pkg.name, + description: pkg.description, + category: pkg.category, + latestVersion: pkg.latestVersion, + affectedHostsCount: pkg.hostPackages.length, + isSecurityUpdate: pkg.hostPackages.some(hp => hp.isSecurityUpdate), + affectedHosts: pkg.hostPackages.map(hp => ({ + hostId: hp.host.id, + hostname: hp.host.hostname, + osType: hp.host.osType, + currentVersion: hp.currentVersion, + availableVersion: hp.availableVersion, + isSecurityUpdate: hp.isSecurityUpdate + })) + })); + + res.json(packagesWithHostInfo); + } catch (error) { + console.error('Error fetching packages:', error); + res.status(500).json({ error: 'Failed to fetch packages' }); + } +}); + +// Get detailed host information +router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, res) => { + try { + const { hostId } = req.params; + + const host = await prisma.host.findUnique({ + where: { id: hostId }, + include: { + hostGroup: { + select: { + id: true, + name: true, + color: true + } + }, + hostPackages: { + include: { + package: true + }, + orderBy: { + needsUpdate: 'desc' + } + }, + updateHistory: { + orderBy: { + timestamp: 'desc' + }, + take: 10 + } + } + }); + + if (!host) { + return res.status(404).json({ error: 'Host not found' }); + } + + const hostWithStats = { + ...host, + stats: { + totalPackages: host.hostPackages.length, + outdatedPackages: host.hostPackages.filter(hp => hp.needsUpdate).length, + securityUpdates: host.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length + } + }; + + res.json(hostWithStats); + } catch (error) { + console.error('Error fetching host details:', error); + res.status(500).json({ error: 'Failed to fetch host details' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/hostGroupRoutes.js b/backend/src/routes/hostGroupRoutes.js new file mode 100644 index 0000000..6084376 --- /dev/null +++ b/backend/src/routes/hostGroupRoutes.js @@ -0,0 +1,225 @@ +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const { PrismaClient } = require('@prisma/client'); +const { authenticateToken } = require('../middleware/auth'); +const { requireManageHosts } = require('../middleware/permissions'); + +const router = express.Router(); +const prisma = new PrismaClient(); + +// Get all host groups +router.get('/', authenticateToken, async (req, res) => { + try { + const hostGroups = await prisma.hostGroup.findMany({ + include: { + _count: { + select: { + hosts: true + } + } + }, + orderBy: { + name: 'asc' + } + }); + + res.json(hostGroups); + } catch (error) { + console.error('Error fetching host groups:', error); + res.status(500).json({ error: 'Failed to fetch host groups' }); + } +}); + +// Get a specific host group by ID +router.get('/:id', authenticateToken, async (req, res) => { + try { + const { id } = req.params; + + const hostGroup = await prisma.hostGroup.findUnique({ + where: { id }, + include: { + hosts: { + select: { + id: true, + hostname: true, + ip: true, + osType: true, + osVersion: true, + status: true, + lastUpdate: true + } + } + } + }); + + if (!hostGroup) { + return res.status(404).json({ error: 'Host group not found' }); + } + + res.json(hostGroup); + } catch (error) { + console.error('Error fetching host group:', error); + res.status(500).json({ error: 'Failed to fetch host group' }); + } +}); + +// Create a new host group +router.post('/', authenticateToken, requireManageHosts, [ + body('name').trim().isLength({ min: 1 }).withMessage('Name is required'), + body('description').optional().trim(), + body('color').optional().isHexColor().withMessage('Color must be a valid hex color') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { name, description, color } = req.body; + + // Check if host group with this name already exists + const existingGroup = await prisma.hostGroup.findUnique({ + where: { name } + }); + + if (existingGroup) { + return res.status(400).json({ error: 'A host group with this name already exists' }); + } + + const hostGroup = await prisma.hostGroup.create({ + data: { + name, + description: description || null, + color: color || '#3B82F6' + } + }); + + res.status(201).json(hostGroup); + } catch (error) { + console.error('Error creating host group:', error); + res.status(500).json({ error: 'Failed to create host group' }); + } +}); + +// Update a host group +router.put('/:id', authenticateToken, requireManageHosts, [ + body('name').trim().isLength({ min: 1 }).withMessage('Name is required'), + body('description').optional().trim(), + body('color').optional().isHexColor().withMessage('Color must be a valid hex color') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { id } = req.params; + const { name, description, color } = req.body; + + // Check if host group exists + const existingGroup = await prisma.hostGroup.findUnique({ + where: { id } + }); + + if (!existingGroup) { + return res.status(404).json({ error: 'Host group not found' }); + } + + // Check if another host group with this name already exists + const duplicateGroup = await prisma.hostGroup.findFirst({ + where: { + name, + id: { not: id } + } + }); + + if (duplicateGroup) { + return res.status(400).json({ error: 'A host group with this name already exists' }); + } + + const hostGroup = await prisma.hostGroup.update({ + where: { id }, + data: { + name, + description: description || null, + color: color || '#3B82F6' + } + }); + + res.json(hostGroup); + } catch (error) { + console.error('Error updating host group:', error); + res.status(500).json({ error: 'Failed to update host group' }); + } +}); + +// Delete a host group +router.delete('/:id', authenticateToken, requireManageHosts, async (req, res) => { + try { + const { id } = req.params; + + // Check if host group exists + const existingGroup = await prisma.hostGroup.findUnique({ + where: { id }, + include: { + _count: { + select: { + hosts: true + } + } + } + }); + + if (!existingGroup) { + return res.status(404).json({ error: 'Host group not found' }); + } + + // Check if host group has hosts + if (existingGroup._count.hosts > 0) { + return res.status(400).json({ + error: 'Cannot delete host group that contains hosts. Please move or remove hosts first.' + }); + } + + await prisma.hostGroup.delete({ + where: { id } + }); + + res.json({ message: 'Host group deleted successfully' }); + } catch (error) { + console.error('Error deleting host group:', error); + res.status(500).json({ error: 'Failed to delete host group' }); + } +}); + +// Get hosts in a specific group +router.get('/:id/hosts', authenticateToken, async (req, res) => { + try { + const { id } = req.params; + + const hosts = await prisma.host.findMany({ + where: { hostGroupId: id }, + select: { + id: true, + hostname: true, + ip: true, + osType: true, + osVersion: true, + architecture: true, + status: true, + lastUpdate: true, + createdAt: true + }, + orderBy: { + hostname: 'asc' + } + }); + + res.json(hosts); + } catch (error) { + console.error('Error fetching hosts in group:', error); + res.status(500).json({ error: 'Failed to fetch hosts in group' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/hostRoutes.js b/backend/src/routes/hostRoutes.js new file mode 100644 index 0000000..7b15beb --- /dev/null +++ b/backend/src/routes/hostRoutes.js @@ -0,0 +1,928 @@ +const express = require('express'); +const { PrismaClient } = require('@prisma/client'); +const { body, validationResult } = require('express-validator'); +const { v4: uuidv4 } = require('uuid'); +const bcrypt = require('bcryptjs'); +const crypto = require('crypto'); +const path = require('path'); +const fs = require('fs'); +const { authenticateToken, requireAdmin } = require('../middleware/auth'); +const { requireManageHosts, requireManageSettings } = require('../middleware/permissions'); + +const router = express.Router(); +const prisma = new PrismaClient(); + +// Public endpoint to download the agent script +router.get('/agent/download', async (req, res) => { + try { + const { version } = req.query; + + let agentVersion; + + if (version) { + // Download specific version + agentVersion = await prisma.agentVersion.findUnique({ + where: { version } + }); + + if (!agentVersion) { + return res.status(404).json({ error: 'Agent version not found' }); + } + } else { + // Download current version (latest) + agentVersion = await prisma.agentVersion.findFirst({ + where: { isCurrent: true }, + orderBy: { createdAt: 'desc' } + }); + + if (!agentVersion) { + // Fallback to default version + agentVersion = await prisma.agentVersion.findFirst({ + where: { isDefault: true }, + orderBy: { createdAt: 'desc' } + }); + } + } + + if (!agentVersion) { + return res.status(404).json({ error: 'No agent version available' }); + } + + // Use script content from database if available, otherwise fallback to file + if (agentVersion.scriptContent) { + res.setHeader('Content-Type', 'application/x-shellscript'); + res.setHeader('Content-Disposition', `attachment; filename="patchmon-agent-${agentVersion.version}.sh"`); + res.send(agentVersion.scriptContent); + } else { + // Fallback to file system + const agentPath = path.join(__dirname, '../../../agents/patchmon-agent.sh'); + + if (!fs.existsSync(agentPath)) { + return res.status(404).json({ error: 'Agent script not found' }); + } + + res.setHeader('Content-Type', 'application/x-shellscript'); + res.setHeader('Content-Disposition', `attachment; filename="patchmon-agent-${agentVersion.version}.sh"`); + res.sendFile(path.resolve(agentPath)); + } + } catch (error) { + console.error('Agent download error:', error); + res.status(500).json({ error: 'Failed to download agent script' }); + } +}); + +// Version check endpoint for agents +router.get('/agent/version', async (req, res) => { + try { + const currentVersion = await prisma.agentVersion.findFirst({ + where: { isCurrent: true }, + orderBy: { createdAt: 'desc' } + }); + + if (!currentVersion) { + return res.status(404).json({ error: 'No current agent version found' }); + } + + res.json({ + currentVersion: currentVersion.version, + downloadUrl: currentVersion.downloadUrl || `/api/v1/hosts/agent/download`, + releaseNotes: currentVersion.releaseNotes, + minServerVersion: currentVersion.minServerVersion + }); + } catch (error) { + console.error('Version check error:', error); + res.status(500).json({ error: 'Failed to get agent version' }); + } +}); + +// Generate API credentials +const generateApiCredentials = () => { + const apiId = `patchmon_${crypto.randomBytes(8).toString('hex')}`; + const apiKey = crypto.randomBytes(32).toString('hex'); + return { apiId, apiKey }; +}; + +// Middleware to validate API credentials +const validateApiCredentials = async (req, res, next) => { + try { + const apiId = req.headers['x-api-id'] || req.body.apiId; + const apiKey = req.headers['x-api-key'] || req.body.apiKey; + + if (!apiId || !apiKey) { + return res.status(401).json({ error: 'API ID and Key required' }); + } + + const host = await prisma.host.findFirst({ + where: { + apiId: apiId, + apiKey: apiKey + } + }); + + if (!host) { + return res.status(401).json({ error: 'Invalid API credentials' }); + } + + req.hostRecord = host; + next(); + } catch (error) { + console.error('API credential validation error:', error); + res.status(500).json({ error: 'API credential validation failed' }); + } +}; + +// Admin endpoint to create a new host manually (replaces auto-registration) +router.post('/create', authenticateToken, requireManageHosts, [ + body('hostname').isLength({ min: 1 }).withMessage('Hostname is required'), + body('hostGroupId').optional() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { hostname, hostGroupId } = req.body; + + // Generate unique API credentials for this host + const { apiId, apiKey } = generateApiCredentials(); + + // Check if host already exists + const existingHost = await prisma.host.findUnique({ + where: { hostname } + }); + + if (existingHost) { + return res.status(409).json({ error: 'Host already exists' }); + } + + // If hostGroupId is provided, verify the group exists + if (hostGroupId) { + const hostGroup = await prisma.hostGroup.findUnique({ + where: { id: hostGroupId } + }); + + if (!hostGroup) { + return res.status(400).json({ error: 'Host group not found' }); + } + } + + // Create new host with API credentials - system info will be populated when agent connects + const host = await prisma.host.create({ + data: { + hostname, + osType: 'unknown', // Will be updated when agent connects + osVersion: 'unknown', // Will be updated when agent connects + ip: null, // Will be updated when agent connects + architecture: null, // Will be updated when agent connects + apiId, + apiKey, + hostGroupId: hostGroupId || null, + status: 'pending' // Will change to 'active' when agent connects + }, + include: { + hostGroup: { + select: { + id: true, + name: true, + color: true + } + } + } + }); + + res.status(201).json({ + message: 'Host created successfully', + hostId: host.id, + hostname: host.hostname, + apiId: host.apiId, + apiKey: host.apiKey, + hostGroup: host.hostGroup, + instructions: 'Use these credentials in your patchmon agent configuration. System information will be automatically detected when the agent connects.' + }); + } catch (error) { + console.error('Host creation error:', error); + res.status(500).json({ error: 'Failed to create host' }); + } +}); + +// Legacy register endpoint (deprecated - returns error message) +router.post('/register', async (req, res) => { + res.status(400).json({ + error: 'Host registration has been disabled. Please contact your administrator to add this host to PatchMon.', + deprecated: true, + message: 'Hosts must now be pre-created by administrators with specific API credentials.' + }); +}); + +// Update host information and packages (now uses API credentials) +router.post('/update', validateApiCredentials, [ + body('packages').isArray().withMessage('Packages must be an array'), + body('packages.*.name').isLength({ min: 1 }).withMessage('Package name is required'), + body('packages.*.currentVersion').isLength({ min: 1 }).withMessage('Current version is required'), + body('packages.*.availableVersion').optional().isLength({ min: 1 }), + body('packages.*.needsUpdate').isBoolean().withMessage('needsUpdate must be boolean'), + body('packages.*.isSecurityUpdate').optional().isBoolean().withMessage('isSecurityUpdate must be boolean'), + body('agentVersion').optional().isLength({ min: 1 }).withMessage('Agent version must be a non-empty string') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { packages, repositories } = req.body; + const host = req.hostRecord; + + // Update host last update timestamp and OS info if provided + const updateData = { lastUpdate: new Date() }; + if (req.body.osType) updateData.osType = req.body.osType; + if (req.body.osVersion) updateData.osVersion = req.body.osVersion; + if (req.body.ip) updateData.ip = req.body.ip; + if (req.body.architecture) updateData.architecture = req.body.architecture; + if (req.body.agentVersion) updateData.agentVersion = req.body.agentVersion; + + // If this is the first update (status is 'pending'), change to 'active' + if (host.status === 'pending') { + updateData.status = 'active'; + } + + await prisma.host.update({ + where: { id: host.id }, + data: updateData + }); + + // Process packages in transaction + await prisma.$transaction(async (tx) => { + // Clear existing host packages + await tx.hostPackage.deleteMany({ + where: { hostId: host.id } + }); + + // Process each package + for (const packageData of packages) { + // Find or create package + let pkg = await tx.package.findUnique({ + where: { name: packageData.name } + }); + + if (!pkg) { + pkg = await tx.package.create({ + data: { + name: packageData.name, + description: packageData.description || null, + category: packageData.category || null, + latestVersion: packageData.availableVersion || packageData.currentVersion + } + }); + } else { + // Update package latest version if newer + if (packageData.availableVersion && packageData.availableVersion !== pkg.latestVersion) { + await tx.package.update({ + where: { id: pkg.id }, + data: { latestVersion: packageData.availableVersion } + }); + } + } + + // Create host package relationship + await tx.hostPackage.create({ + data: { + hostId: host.id, + packageId: pkg.id, + currentVersion: packageData.currentVersion, + availableVersion: packageData.availableVersion || null, + needsUpdate: packageData.needsUpdate, + isSecurityUpdate: packageData.isSecurityUpdate || false, + lastChecked: new Date() + } + }); + } + + // Process repositories if provided + if (repositories && Array.isArray(repositories)) { + // Clear existing host repositories + await tx.hostRepository.deleteMany({ + where: { hostId: host.id } + }); + + // Process each repository + for (const repoData of repositories) { + // Find or create repository + let repo = await tx.repository.findFirst({ + where: { + url: repoData.url, + distribution: repoData.distribution, + components: repoData.components + } + }); + + if (!repo) { + repo = await tx.repository.create({ + data: { + name: repoData.name, + url: repoData.url, + distribution: repoData.distribution, + components: repoData.components, + repoType: repoData.repoType, + isActive: true, + isSecure: repoData.isSecure || false, + description: `${repoData.repoType} repository for ${repoData.distribution}` + } + }); + } + + // Create host repository relationship + await tx.hostRepository.create({ + data: { + hostId: host.id, + repositoryId: repo.id, + isEnabled: repoData.isEnabled !== false, // Default to enabled + lastChecked: new Date() + } + }); + } + } + }); + + // Create update history record + const securityCount = packages.filter(pkg => pkg.isSecurityUpdate).length; + const updatesCount = packages.filter(pkg => pkg.needsUpdate).length; + + await prisma.updateHistory.create({ + data: { + hostId: host.id, + packagesCount: updatesCount, + securityCount, + status: 'success' + } + }); + + // Check if auto-update is enabled and if there's a newer agent version available + let autoUpdateResponse = null; + try { + const settings = await prisma.settings.findFirst(); + // Check both global auto-update setting AND host-specific auto-update setting + if (settings && settings.autoUpdate && host.autoUpdate) { + // Get current agent version from the request + const currentAgentVersion = req.body.agentVersion; + + if (currentAgentVersion) { + // Get the latest agent version + const latestAgentVersion = await prisma.agentVersion.findFirst({ + where: { isCurrent: true }, + orderBy: { createdAt: 'desc' } + }); + + if (latestAgentVersion && latestAgentVersion.version !== currentAgentVersion) { + // There's a newer version available + autoUpdateResponse = { + shouldUpdate: true, + currentVersion: currentAgentVersion, + latestVersion: latestAgentVersion.version, + message: 'A newer agent version is available. Run: /usr/local/bin/patchmon-agent.sh update-agent', + updateCommand: 'update-agent' + }; + } + } + } + } catch (error) { + console.error('Auto-update check error:', error); + // Don't fail the update if auto-update check fails + } + + const response = { + message: 'Host updated successfully', + packagesProcessed: packages.length, + updatesAvailable: updatesCount, + securityUpdates: securityCount + }; + + // Add auto-update response if available + if (autoUpdateResponse) { + response.autoUpdate = autoUpdateResponse; + } + + // Check if crontab update is needed (when update interval changes) + // This is a simple check - if the host has auto-update enabled, we'll suggest crontab update + if (host.autoUpdate) { + // For now, we'll always suggest crontab update to ensure it's current + // In a more sophisticated implementation, we could track when the interval last changed + response.crontabUpdate = { + shouldUpdate: true, + message: 'Please ensure your crontab is up to date with current interval settings', + command: 'update-crontab' + }; + } + + res.json(response); + } catch (error) { + console.error('Host update error:', error); + + // Log error in update history + try { + await prisma.updateHistory.create({ + data: { + hostId: req.hostRecord.id, + packagesCount: 0, + securityCount: 0, + status: 'error', + errorMessage: error.message + } + }); + } catch (logError) { + console.error('Failed to log update error:', logError); + } + + res.status(500).json({ error: 'Failed to update host' }); + } +}); + +// Get host information (now uses API credentials) +router.get('/info', validateApiCredentials, async (req, res) => { + try { + const host = await prisma.host.findUnique({ + where: { id: req.hostRecord.id }, + select: { + id: true, + hostname: true, + ip: true, + osType: true, + osVersion: true, + architecture: true, + lastUpdate: true, + status: true, + createdAt: true, + apiId: true // Include API ID for reference + } + }); + + res.json(host); + } catch (error) { + console.error('Get host info error:', error); + res.status(500).json({ error: 'Failed to fetch host information' }); + } +}); + +// Ping endpoint for health checks (now uses API credentials) +router.post('/ping', validateApiCredentials, async (req, res) => { + try { + // Update last update timestamp + await prisma.host.update({ + where: { id: req.hostRecord.id }, + data: { lastUpdate: new Date() } + }); + + const response = { + message: 'Ping successful', + timestamp: new Date().toISOString(), + hostname: req.hostRecord.hostname + }; + + // Check if this is a crontab update trigger + if (req.body.triggerCrontabUpdate && req.hostRecord.autoUpdate) { + console.log(`Triggering crontab update for host: ${req.hostRecord.hostname}`); + response.crontabUpdate = { + shouldUpdate: true, + message: 'Update interval changed, please run: /usr/local/bin/patchmon-agent.sh update-crontab', + command: 'update-crontab' + }; + } + + res.json(response); + } catch (error) { + console.error('Ping error:', error); + res.status(500).json({ error: 'Ping failed' }); + } +}); + +// Admin endpoint to regenerate API credentials for a host +router.post('/:hostId/regenerate-credentials', authenticateToken, requireManageHosts, async (req, res) => { + try { + const { hostId } = req.params; + + const host = await prisma.host.findUnique({ + where: { id: hostId } + }); + + if (!host) { + return res.status(404).json({ error: 'Host not found' }); + } + + // Generate new API credentials + const { apiId, apiKey } = generateApiCredentials(); + + // Update host with new credentials + const updatedHost = await prisma.host.update({ + where: { id: hostId }, + data: { apiId, apiKey } + }); + + res.json({ + message: 'API credentials regenerated successfully', + hostname: updatedHost.hostname, + apiId: updatedHost.apiId, + apiKey: updatedHost.apiKey, + warning: 'Previous credentials are now invalid. Update your agent configuration.' + }); + } catch (error) { + console.error('Credential regeneration error:', error); + res.status(500).json({ error: 'Failed to regenerate credentials' }); + } +}); + +// Admin endpoint to bulk update host groups +router.put('/bulk/group', authenticateToken, requireManageHosts, [ + body('hostIds').isArray().withMessage('Host IDs must be an array'), + body('hostIds.*').isLength({ min: 1 }).withMessage('Each host ID must be provided'), + body('hostGroupId').optional() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { hostIds, hostGroupId } = req.body; + + // If hostGroupId is provided, verify the group exists + if (hostGroupId) { + const hostGroup = await prisma.hostGroup.findUnique({ + where: { id: hostGroupId } + }); + + if (!hostGroup) { + return res.status(400).json({ error: 'Host group not found' }); + } + } + + // Check if all hosts exist + const existingHosts = await prisma.host.findMany({ + where: { id: { in: hostIds } }, + select: { id: true, hostname: true } + }); + + if (existingHosts.length !== hostIds.length) { + const foundIds = existingHosts.map(h => h.id); + const missingIds = hostIds.filter(id => !foundIds.includes(id)); + return res.status(400).json({ + error: 'Some hosts not found', + missingHostIds: missingIds + }); + } + + // Bulk update host groups + const updateResult = await prisma.host.updateMany({ + where: { id: { in: hostIds } }, + data: { + hostGroupId: hostGroupId || null + } + }); + + // Get updated hosts with group information + const updatedHosts = await prisma.host.findMany({ + where: { id: { in: hostIds } }, + select: { + id: true, + hostname: true, + hostGroup: { + select: { + id: true, + name: true, + color: true + } + } + } + }); + + res.json({ + message: `Successfully updated ${updateResult.count} host${updateResult.count !== 1 ? 's' : ''}`, + updatedCount: updateResult.count, + hosts: updatedHosts + }); + } catch (error) { + console.error('Bulk host group update error:', error); + res.status(500).json({ error: 'Failed to update host groups' }); + } +}); + +// Admin endpoint to update host group +router.put('/:hostId/group', authenticateToken, requireManageHosts, [ + body('hostGroupId').optional() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { hostId } = req.params; + const { hostGroupId } = req.body; + + // Check if host exists + const host = await prisma.host.findUnique({ + where: { id: hostId } + }); + + if (!host) { + return res.status(404).json({ error: 'Host not found' }); + } + + // If hostGroupId is provided, verify the group exists + if (hostGroupId) { + const hostGroup = await prisma.hostGroup.findUnique({ + where: { id: hostGroupId } + }); + + if (!hostGroup) { + return res.status(400).json({ error: 'Host group not found' }); + } + } + + // Update host group + const updatedHost = await prisma.host.update({ + where: { id: hostId }, + data: { + hostGroupId: hostGroupId || null + }, + include: { + hostGroup: { + select: { + id: true, + name: true, + color: true + } + } + } + }); + + res.json({ + message: 'Host group updated successfully', + host: updatedHost + }); + } catch (error) { + console.error('Host group update error:', error); + res.status(500).json({ error: 'Failed to update host group' }); + } +}); + +// Admin endpoint to list all hosts +router.get('/admin/list', authenticateToken, requireManageHosts, async (req, res) => { + try { + const hosts = await prisma.host.findMany({ + select: { + id: true, + hostname: true, + ip: true, + osType: true, + osVersion: true, + architecture: true, + lastUpdate: true, + status: true, + apiId: true, + agentVersion: true, + autoUpdate: true, + createdAt: true + }, + orderBy: { createdAt: 'desc' } + }); + + res.json(hosts); + } catch (error) { + console.error('List hosts error:', error); + res.status(500).json({ error: 'Failed to fetch hosts' }); + } +}); + +// Admin endpoint to delete host +router.delete('/:hostId', authenticateToken, requireManageHosts, async (req, res) => { + try { + const { hostId } = req.params; + + // Delete host and all related data (cascade) + await prisma.host.delete({ + where: { id: hostId } + }); + + res.json({ message: 'Host deleted successfully' }); + } catch (error) { + console.error('Host deletion error:', error); + res.status(500).json({ error: 'Failed to delete host' }); + } +}); + +// Toggle host auto-update setting +router.patch('/:hostId/auto-update', authenticateToken, requireManageHosts, [ + body('autoUpdate').isBoolean().withMessage('Auto-update must be a boolean') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { hostId } = req.params; + const { autoUpdate } = req.body; + + const host = await prisma.host.update({ + where: { id: hostId }, + data: { autoUpdate } + }); + + res.json({ + message: `Host auto-update ${autoUpdate ? 'enabled' : 'disabled'} successfully`, + host: { + id: host.id, + hostname: host.hostname, + autoUpdate: host.autoUpdate + } + }); + } catch (error) { + console.error('Host auto-update toggle error:', error); + res.status(500).json({ error: 'Failed to toggle host auto-update' }); + } +}); + +// Serve the installation script +router.get('/install', async (req, res) => { + try { + const fs = require('fs'); + const path = require('path'); + + const scriptPath = path.join(__dirname, '../../../agents/patchmon_install.sh'); + + if (!fs.existsSync(scriptPath)) { + return res.status(404).json({ error: 'Installation script not found' }); + } + + let script = fs.readFileSync(scriptPath, 'utf8'); + + // Get the configured server URL from settings + try { + const settings = await prisma.settings.findFirst(); + if (settings) { + // Replace the default server URL in the script with the configured one + script = script.replace( + /PATCHMON_URL="[^"]*"/g, + `PATCHMON_URL="${settings.serverUrl}"` + ); + } + } catch (settingsError) { + console.warn('Could not fetch settings, using default server URL:', settingsError.message); + } + + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Content-Disposition', 'inline; filename="patchmon_install.sh"'); + res.send(script); + } catch (error) { + console.error('Installation script error:', error); + res.status(500).json({ error: 'Failed to serve installation script' }); + } +}); + +// ==================== AGENT VERSION MANAGEMENT ==================== + +// Get all agent versions (admin only) +router.get('/agent/versions', authenticateToken, requireManageSettings, async (req, res) => { + try { + const versions = await prisma.agentVersion.findMany({ + orderBy: { createdAt: 'desc' } + }); + + res.json(versions); + } catch (error) { + console.error('Get agent versions error:', error); + res.status(500).json({ error: 'Failed to get agent versions' }); + } +}); + +// Create new agent version (admin only) +router.post('/agent/versions', authenticateToken, requireManageSettings, [ + body('version').isLength({ min: 1 }).withMessage('Version is required'), + body('releaseNotes').optional().isString(), + body('downloadUrl').optional().isURL().withMessage('Download URL must be valid'), + body('minServerVersion').optional().isString(), + body('scriptContent').optional().isString(), + body('isDefault').optional().isBoolean() +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { version, releaseNotes, downloadUrl, minServerVersion, scriptContent, isDefault } = req.body; + + // Check if version already exists + const existingVersion = await prisma.agentVersion.findUnique({ + where: { version } + }); + + if (existingVersion) { + return res.status(400).json({ error: 'Version already exists' }); + } + + // If this is being set as default, unset other defaults + if (isDefault) { + await prisma.agentVersion.updateMany({ + where: { isDefault: true }, + data: { isDefault: false } + }); + } + + const agentVersion = await prisma.agentVersion.create({ + data: { + version, + releaseNotes, + downloadUrl, + minServerVersion, + scriptContent, + isDefault: isDefault || false, + isCurrent: false + } + }); + + res.status(201).json(agentVersion); + } catch (error) { + console.error('Create agent version error:', error); + res.status(500).json({ error: 'Failed to create agent version' }); + } +}); + +// Set current agent version (admin only) +router.patch('/agent/versions/:versionId/current', authenticateToken, requireManageSettings, async (req, res) => { + try { + const { versionId } = req.params; + + // First, unset all current versions + await prisma.agentVersion.updateMany({ + where: { isCurrent: true }, + data: { isCurrent: false } + }); + + // Set the specified version as current + const agentVersion = await prisma.agentVersion.update({ + where: { id: versionId }, + data: { isCurrent: true } + }); + + res.json(agentVersion); + } catch (error) { + console.error('Set current agent version error:', error); + res.status(500).json({ error: 'Failed to set current agent version' }); + } +}); + +// Set default agent version (admin only) +router.patch('/agent/versions/:versionId/default', authenticateToken, requireManageSettings, async (req, res) => { + try { + const { versionId } = req.params; + + // First, unset all default versions + await prisma.agentVersion.updateMany({ + where: { isDefault: true }, + data: { isDefault: false } + }); + + // Set the specified version as default + const agentVersion = await prisma.agentVersion.update({ + where: { id: versionId }, + data: { isDefault: true } + }); + + res.json(agentVersion); + } catch (error) { + console.error('Set default agent version error:', error); + res.status(500).json({ error: 'Failed to set default agent version' }); + } +}); + +// Delete agent version (admin only) +router.delete('/agent/versions/:versionId', authenticateToken, requireManageSettings, async (req, res) => { + try { + const { versionId } = req.params; + + const agentVersion = await prisma.agentVersion.findUnique({ + where: { id: versionId } + }); + + if (!agentVersion) { + return res.status(404).json({ error: 'Agent version not found' }); + } + + if (agentVersion.isCurrent) { + return res.status(400).json({ error: 'Cannot delete current agent version' }); + } + + await prisma.agentVersion.delete({ + where: { id: versionId } + }); + + res.json({ message: 'Agent version deleted successfully' }); + } catch (error) { + console.error('Delete agent version error:', error); + res.status(500).json({ error: 'Failed to delete agent version' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/packageRoutes.js b/backend/src/routes/packageRoutes.js new file mode 100644 index 0000000..ca22234 --- /dev/null +++ b/backend/src/routes/packageRoutes.js @@ -0,0 +1,213 @@ +const express = require('express'); +const { PrismaClient } = require('@prisma/client'); +const { body, validationResult } = require('express-validator'); + +const router = express.Router(); +const prisma = new PrismaClient(); + +// Get all packages with their update status +router.get('/', async (req, res) => { + try { + const { + page = 1, + limit = 50, + search = '', + category = '', + needsUpdate = '', + isSecurityUpdate = '' + } = req.query; + + const skip = (parseInt(page) - 1) * parseInt(limit); + const take = parseInt(limit); + + // Build where clause + const where = { + AND: [ + // Search filter + search ? { + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { description: { contains: search, mode: 'insensitive' } } + ] + } : {}, + // Category filter + category ? { category: { equals: category } } : {}, + // Update status filters + needsUpdate ? { + hostPackages: { + some: { + needsUpdate: needsUpdate === 'true' + } + } + } : {}, + isSecurityUpdate ? { + hostPackages: { + some: { + isSecurityUpdate: isSecurityUpdate === 'true' + } + } + } : {} + ] + }; + + // Get packages with counts + const [packages, totalCount] = await Promise.all([ + prisma.package.findMany({ + where, + select: { + id: true, + name: true, + description: true, + category: true, + latestVersion: true, + createdAt: true, + _count: { + hostPackages: true + } + }, + skip, + take, + orderBy: { + name: 'asc' + } + }), + prisma.package.count({ where }) + ]); + + // Get additional stats for each package + const packagesWithStats = await Promise.all( + packages.map(async (pkg) => { + const [updatesCount, securityCount, affectedHosts] = await Promise.all([ + prisma.hostPackage.count({ + where: { + packageId: pkg.id, + needsUpdate: true + } + }), + prisma.hostPackage.count({ + where: { + packageId: pkg.id, + needsUpdate: true, + isSecurityUpdate: true + } + }), + prisma.hostPackage.findMany({ + where: { + packageId: pkg.id, + needsUpdate: true + }, + select: { + host: { + select: { + id: true, + hostname: true, + osType: true + } + } + }, + take: 10 // Limit to first 10 for performance + }) + ]); + + return { + ...pkg, + stats: { + totalInstalls: pkg._count.hostPackages, + updatesNeeded: updatesCount, + securityUpdates: securityCount, + affectedHosts: affectedHosts.map(hp => hp.host) + } + }; + }) + ); + + res.json({ + packages: packagesWithStats, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total: totalCount, + pages: Math.ceil(totalCount / parseInt(limit)) + } + }); + } catch (error) { + console.error('Error fetching packages:', error); + res.status(500).json({ error: 'Failed to fetch packages' }); + } +}); + +// Get package details by ID +router.get('/:packageId', async (req, res) => { + try { + const { packageId } = req.params; + + const packageData = await prisma.package.findUnique({ + where: { id: packageId }, + include: { + hostPackages: { + include: { + host: { + select: { + id: true, + hostname: true, + ip: true, + osType: true, + osVersion: true, + lastUpdate: true + } + } + }, + orderBy: { + needsUpdate: 'desc' + } + } + } + }); + + if (!packageData) { + return res.status(404).json({ error: 'Package not found' }); + } + + // Calculate statistics + const stats = { + totalInstalls: packageData.hostPackages.length, + updatesNeeded: packageData.hostPackages.filter(hp => hp.needsUpdate).length, + securityUpdates: packageData.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length, + upToDate: packageData.hostPackages.filter(hp => !hp.needsUpdate).length + }; + + // Group by version + const versionDistribution = packageData.hostPackages.reduce((acc, hp) => { + const version = hp.currentVersion; + acc[version] = (acc[version] || 0) + 1; + return acc; + }, {}); + + // Group by OS type + const osDistribution = packageData.hostPackages.reduce((acc, hp) => { + const osType = hp.host.osType; + acc[osType] = (acc[osType] || 0) + 1; + return acc; + }, {}); + + res.json({ + ...packageData, + stats, + distributions: { + versions: Object.entries(versionDistribution).map(([version, count]) => ({ + version, + count + })), + osTypes: Object.entries(osDistribution).map(([osType, count]) => ({ + osType, + count + })) + } + }); + } catch (error) { + console.error('Error fetching package details:', error); + res.status(500).json({ error: 'Failed to fetch package details' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/permissionsRoutes.js b/backend/src/routes/permissionsRoutes.js new file mode 100644 index 0000000..493973b --- /dev/null +++ b/backend/src/routes/permissionsRoutes.js @@ -0,0 +1,173 @@ +const express = require('express'); +const { PrismaClient } = require('@prisma/client'); +const { authenticateToken, requireAdmin } = require('../middleware/auth'); +const { requireManageSettings } = require('../middleware/permissions'); + +const router = express.Router(); +const prisma = new PrismaClient(); + +// Get all role permissions +router.get('/roles', authenticateToken, requireManageSettings, async (req, res) => { + try { + const permissions = await prisma.rolePermissions.findMany({ + orderBy: { + role: 'asc' + } + }); + + res.json(permissions); + } catch (error) { + console.error('Get role permissions error:', error); + res.status(500).json({ error: 'Failed to fetch role permissions' }); + } +}); + +// Get permissions for a specific role +router.get('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => { + try { + const { role } = req.params; + + const permissions = await prisma.rolePermissions.findUnique({ + where: { role } + }); + + if (!permissions) { + return res.status(404).json({ error: 'Role not found' }); + } + + res.json(permissions); + } catch (error) { + console.error('Get role permission error:', error); + res.status(500).json({ error: 'Failed to fetch role permission' }); + } +}); + +// Create or update role permissions +router.put('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => { + try { + const { role } = req.params; + const { + canViewDashboard, + canViewHosts, + canManageHosts, + canViewPackages, + canManagePackages, + canViewUsers, + canManageUsers, + canViewReports, + canExportData, + canManageSettings + } = req.body; + + // Prevent modifying admin role permissions (admin should always have full access) + if (role === 'admin') { + return res.status(400).json({ error: 'Cannot modify admin role permissions' }); + } + + const permissions = await prisma.rolePermissions.upsert({ + where: { role }, + update: { + canViewDashboard, + canViewHosts, + canManageHosts, + canViewPackages, + canManagePackages, + canViewUsers, + canManageUsers, + canViewReports, + canExportData, + canManageSettings + }, + create: { + role, + canViewDashboard, + canViewHosts, + canManageHosts, + canViewPackages, + canManagePackages, + canViewUsers, + canManageUsers, + canViewReports, + canExportData, + canManageSettings + } + }); + + res.json({ + message: 'Role permissions updated successfully', + permissions + }); + } catch (error) { + console.error('Update role permissions error:', error); + res.status(500).json({ error: 'Failed to update role permissions' }); + } +}); + +// Delete a role (and its permissions) +router.delete('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => { + try { + const { role } = req.params; + + // Prevent deleting admin role + if (role === 'admin') { + return res.status(400).json({ error: 'Cannot delete admin role' }); + } + + // Check if any users are using this role + const usersWithRole = await prisma.user.count({ + where: { role } + }); + + if (usersWithRole > 0) { + return res.status(400).json({ + error: `Cannot delete role "${role}" because ${usersWithRole} user(s) are currently using it` + }); + } + + await prisma.rolePermissions.delete({ + where: { role } + }); + + res.json({ + message: `Role "${role}" deleted successfully` + }); + } catch (error) { + console.error('Delete role error:', error); + res.status(500).json({ error: 'Failed to delete role' }); + } +}); + +// Get user's permissions based on their role +router.get('/user-permissions', authenticateToken, async (req, res) => { + try { + const userRole = req.user.role; + + const permissions = await prisma.rolePermissions.findUnique({ + where: { role: userRole } + }); + + if (!permissions) { + // If no specific permissions found, return default admin permissions + return res.json({ + role: userRole, + canViewDashboard: true, + canViewHosts: true, + canManageHosts: true, + canViewPackages: true, + canManagePackages: true, + canViewUsers: true, + canManageUsers: true, + canViewReports: true, + canExportData: true, + canManageSettings: true, + }); + } + + res.json(permissions); + } catch (error) { + console.error('Get user permissions error:', error); + res.status(500).json({ error: 'Failed to fetch user permissions' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/repositoryRoutes.js b/backend/src/routes/repositoryRoutes.js new file mode 100644 index 0000000..c275a96 --- /dev/null +++ b/backend/src/routes/repositoryRoutes.js @@ -0,0 +1,301 @@ +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const { PrismaClient } = require('@prisma/client'); +const { authenticateToken } = require('../middleware/auth'); +const { requireViewHosts, requireManageHosts } = require('../middleware/permissions'); + +const router = express.Router(); +const prisma = new PrismaClient(); + +// Get all repositories with host count +router.get('/', authenticateToken, requireViewHosts, async (req, res) => { + try { + const repositories = await prisma.repository.findMany({ + include: { + hostRepositories: { + include: { + host: { + select: { + id: true, + hostname: true, + status: true + } + } + } + }, + _count: { + select: { + hostRepositories: true + } + } + }, + orderBy: [ + { name: 'asc' }, + { url: 'asc' } + ] + }); + + // Transform data to include host counts and status + const transformedRepos = repositories.map(repo => ({ + ...repo, + hostCount: repo._count.hostRepositories, + enabledHostCount: repo.hostRepositories.filter(hr => hr.isEnabled).length, + activeHostCount: repo.hostRepositories.filter(hr => hr.host.status === 'active').length, + hosts: repo.hostRepositories.map(hr => ({ + id: hr.host.id, + hostname: hr.host.hostname, + status: hr.host.status, + isEnabled: hr.isEnabled, + lastChecked: hr.lastChecked + })) + })); + + res.json(transformedRepos); + } catch (error) { + console.error('Repository list error:', error); + res.status(500).json({ error: 'Failed to fetch repositories' }); + } +}); + +// Get repositories for a specific host +router.get('/host/:hostId', authenticateToken, requireViewHosts, async (req, res) => { + try { + const { hostId } = req.params; + + const hostRepositories = await prisma.hostRepository.findMany({ + where: { hostId }, + include: { + repository: true, + host: { + select: { + id: true, + hostname: true + } + } + }, + orderBy: { + repository: { + name: 'asc' + } + } + }); + + res.json(hostRepositories); + } catch (error) { + console.error('Host repositories error:', error); + res.status(500).json({ error: 'Failed to fetch host repositories' }); + } +}); + +// Get repository details with all hosts +router.get('/:repositoryId', authenticateToken, requireViewHosts, async (req, res) => { + try { + const { repositoryId } = req.params; + + const repository = await prisma.repository.findUnique({ + where: { id: repositoryId }, + include: { + hostRepositories: { + include: { + host: { + select: { + id: true, + hostname: true, + ip: true, + osType: true, + osVersion: true, + status: true, + lastUpdate: true + } + } + }, + orderBy: { + host: { + hostname: 'asc' + } + } + } + } + }); + + if (!repository) { + return res.status(404).json({ error: 'Repository not found' }); + } + + res.json(repository); + } catch (error) { + console.error('Repository detail error:', error); + res.status(500).json({ error: 'Failed to fetch repository details' }); + } +}); + +// Update repository information (admin only) +router.put('/:repositoryId', authenticateToken, requireManageHosts, [ + body('name').optional().isLength({ min: 1 }).withMessage('Name is required'), + body('description').optional(), + body('isActive').optional().isBoolean().withMessage('isActive must be a boolean'), + body('priority').optional().isInt({ min: 0 }).withMessage('Priority must be a positive integer') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { repositoryId } = req.params; + const { name, description, isActive, priority } = req.body; + + const repository = await prisma.repository.update({ + where: { id: repositoryId }, + data: { + ...(name && { name }), + ...(description !== undefined && { description }), + ...(isActive !== undefined && { isActive }), + ...(priority !== undefined && { priority }) + }, + include: { + _count: { + select: { + hostRepositories: true + } + } + } + }); + + res.json(repository); + } catch (error) { + console.error('Repository update error:', error); + res.status(500).json({ error: 'Failed to update repository' }); + } +}); + +// Toggle repository status for a specific host +router.patch('/host/:hostId/repository/:repositoryId', authenticateToken, requireManageHosts, [ + body('isEnabled').isBoolean().withMessage('isEnabled must be a boolean') +], async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { hostId, repositoryId } = req.params; + const { isEnabled } = req.body; + + const hostRepository = await prisma.hostRepository.update({ + where: { + hostId_repositoryId: { + hostId, + repositoryId + } + }, + data: { + isEnabled, + lastChecked: new Date() + }, + include: { + repository: true, + host: { + select: { + hostname: true + } + } + } + }); + + res.json({ + message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.host.hostname}`, + hostRepository + }); + } catch (error) { + console.error('Host repository toggle error:', error); + res.status(500).json({ error: 'Failed to toggle repository status' }); + } +}); + +// Get repository statistics +router.get('/stats/summary', authenticateToken, requireViewHosts, async (req, res) => { + try { + const stats = await prisma.repository.aggregate({ + _count: true + }); + + const hostRepoStats = await prisma.hostRepository.aggregate({ + _count: { + isEnabled: true + }, + where: { + isEnabled: true + } + }); + + const secureRepos = await prisma.repository.count({ + where: { isSecure: true } + }); + + const activeRepos = await prisma.repository.count({ + where: { isActive: true } + }); + + res.json({ + totalRepositories: stats._count, + activeRepositories: activeRepos, + secureRepositories: secureRepos, + enabledHostRepositories: hostRepoStats._count.isEnabled, + securityPercentage: stats._count > 0 ? Math.round((secureRepos / stats._count) * 100) : 0 + }); + } catch (error) { + console.error('Repository stats error:', error); + res.status(500).json({ error: 'Failed to fetch repository statistics' }); + } +}); + +// Cleanup orphaned repositories (admin only) +router.delete('/cleanup/orphaned', authenticateToken, requireManageHosts, async (req, res) => { + try { + console.log('Cleaning up orphaned repositories...'); + + // Find repositories with no host relationships + const orphanedRepos = await prisma.repository.findMany({ + where: { + hostRepositories: { + none: {} + } + } + }); + + if (orphanedRepos.length === 0) { + return res.json({ + message: 'No orphaned repositories found', + deletedCount: 0, + deletedRepositories: [] + }); + } + + // Delete orphaned repositories + const deleteResult = await prisma.repository.deleteMany({ + where: { + hostRepositories: { + none: {} + } + } + }); + + console.log(`Deleted ${deleteResult.count} orphaned repositories`); + + res.json({ + message: `Successfully deleted ${deleteResult.count} orphaned repositories`, + deletedCount: deleteResult.count, + deletedRepositories: orphanedRepos.map(repo => ({ + id: repo.id, + name: repo.name, + url: repo.url + })) + }); + } catch (error) { + console.error('Repository cleanup error:', error); + res.status(500).json({ error: 'Failed to cleanup orphaned repositories' }); + } +}); + +module.exports = router; diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js new file mode 100644 index 0000000..9bba6c5 --- /dev/null +++ b/backend/src/routes/settingsRoutes.js @@ -0,0 +1,253 @@ +const express = require('express'); +const { body, validationResult } = require('express-validator'); +const { PrismaClient } = require('@prisma/client'); +const { authenticateToken } = require('../middleware/auth'); +const { requireManageSettings } = require('../middleware/permissions'); + +const router = express.Router(); +const prisma = new PrismaClient(); + +// Function to trigger crontab updates on all hosts with auto-update enabled +async function triggerCrontabUpdates() { + try { + console.log('Triggering crontab updates on all hosts with auto-update enabled...'); + + // Get all hosts that have auto-update enabled + const hosts = await prisma.host.findMany({ + where: { + autoUpdate: true, + status: 'active' // Only update active hosts + }, + select: { + id: true, + hostname: true, + apiId: true, + apiKey: true + } + }); + + console.log(`Found ${hosts.length} hosts with auto-update enabled`); + + // For each host, we'll send a special update command that triggers crontab update + // This is done by sending a ping with a special flag + for (const host of hosts) { + try { + console.log(`Triggering crontab update for host: ${host.hostname}`); + + // We'll use the existing ping endpoint but add a special parameter + // The agent will detect this and run update-crontab command + const http = require('http'); + const https = require('https'); + + const serverUrl = process.env.SERVER_URL || 'http://localhost:3001'; + const url = new URL(`${serverUrl}/api/v1/hosts/ping`); + const isHttps = url.protocol === 'https:'; + const client = isHttps ? https : http; + + const postData = JSON.stringify({ + triggerCrontabUpdate: true, + message: 'Update interval changed, please update your crontab' + }); + + const options = { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + 'X-API-ID': host.apiId, + 'X-API-KEY': host.apiKey + } + }; + + const req = client.request(options, (res) => { + if (res.statusCode === 200) { + console.log(`Successfully triggered crontab update for ${host.hostname}`); + } else { + console.error(`Failed to trigger crontab update for ${host.hostname}: ${res.statusCode}`); + } + }); + + req.on('error', (error) => { + console.error(`Error triggering crontab update for ${host.hostname}:`, error.message); + }); + + req.write(postData); + req.end(); + } catch (error) { + console.error(`Error triggering crontab update for ${host.hostname}:`, error.message); + } + } + + console.log('Crontab update trigger completed'); + } catch (error) { + console.error('Error in triggerCrontabUpdates:', error); + } +} + +// Get current settings +router.get('/', authenticateToken, requireManageSettings, async (req, res) => { + try { + let settings = await prisma.settings.findFirst(); + + // If no settings exist, create default settings + if (!settings) { + settings = await prisma.settings.create({ + data: { + serverUrl: 'http://localhost:3001', + serverProtocol: 'http', + serverHost: 'localhost', + serverPort: 3001, + frontendUrl: 'http://localhost:3000', + updateInterval: 60, + autoUpdate: false + } + }); + } + + console.log('Returning settings:', settings); + res.json(settings); + } catch (error) { + console.error('Settings fetch error:', error); + res.status(500).json({ error: 'Failed to fetch settings' }); + } +}); + +// Update settings +router.put('/', authenticateToken, requireManageSettings, [ + body('serverProtocol').isIn(['http', 'https']).withMessage('Protocol must be http or https'), + body('serverHost').isLength({ min: 1 }).withMessage('Server host is required'), + body('serverPort').isInt({ min: 1, max: 65535 }).withMessage('Port must be between 1 and 65535'), + body('frontendUrl').isLength({ min: 1 }).withMessage('Frontend URL is required'), + body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'), + body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean') +], async (req, res) => { + try { + console.log('Settings update request body:', req.body); + const errors = validationResult(req); + if (!errors.isEmpty()) { + console.log('Validation errors:', errors.array()); + return res.status(400).json({ errors: errors.array() }); + } + + const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate } = req.body; + console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate }); + + // Construct server URL from components + const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`; + + let settings = await prisma.settings.findFirst(); + + if (settings) { + // Update existing settings + console.log('Updating existing settings with data:', { + serverUrl, + serverProtocol, + serverHost, + serverPort, + frontendUrl, + updateInterval: updateInterval || 60, + autoUpdate: autoUpdate || false + }); + const oldUpdateInterval = settings.updateInterval; + + settings = await prisma.settings.update({ + where: { id: settings.id }, + data: { + serverUrl, + serverProtocol, + serverHost, + serverPort, + frontendUrl, + updateInterval: updateInterval || 60, + autoUpdate: autoUpdate || false + } + }); + console.log('Settings updated successfully:', settings); + + // If update interval changed, trigger crontab updates on all hosts with auto-update enabled + if (oldUpdateInterval !== (updateInterval || 60)) { + console.log(`Update interval changed from ${oldUpdateInterval} to ${updateInterval || 60} minutes. Triggering crontab updates...`); + await triggerCrontabUpdates(); + } + } else { + // Create new settings + settings = await prisma.settings.create({ + data: { + serverUrl, + serverProtocol, + serverHost, + serverPort, + frontendUrl, + updateInterval: updateInterval || 60, + autoUpdate: autoUpdate || false + } + }); + } + + res.json({ + message: 'Settings updated successfully', + settings + }); + } catch (error) { + console.error('Settings update error:', error); + res.status(500).json({ error: 'Failed to update settings' }); + } +}); + +// Get server URL for public use (used by installation scripts) +router.get('/server-url', async (req, res) => { + try { + const settings = await prisma.settings.findFirst(); + + if (!settings) { + return res.json({ serverUrl: 'http://localhost:3001' }); + } + + res.json({ serverUrl: settings.serverUrl }); + } catch (error) { + console.error('Server URL fetch error:', error); + res.json({ serverUrl: 'http://localhost:3001' }); + } +}); + +// Get update interval policy for agents (public endpoint) +router.get('/update-interval', async (req, res) => { + try { + const settings = await prisma.settings.findFirst(); + + if (!settings) { + return res.json({ updateInterval: 60 }); + } + + res.json({ + updateInterval: settings.updateInterval, + cronExpression: `*/${settings.updateInterval} * * * *` // Generate cron expression + }); + } catch (error) { + console.error('Update interval fetch error:', error); + res.json({ updateInterval: 60, cronExpression: '0 * * * *' }); + } +}); + +// Get auto-update policy for agents (public endpoint) +router.get('/auto-update', async (req, res) => { + try { + const settings = await prisma.settings.findFirst(); + + if (!settings) { + return res.json({ autoUpdate: false }); + } + + res.json({ + autoUpdate: settings.autoUpdate || false + }); + } catch (error) { + console.error('Auto-update fetch error:', error); + res.json({ autoUpdate: false }); + } +}); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js new file mode 100644 index 0000000..5c4d95a --- /dev/null +++ b/backend/src/server.js @@ -0,0 +1,179 @@ +require('dotenv').config(); +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); +const { PrismaClient } = require('@prisma/client'); +const winston = require('winston'); + +// Import routes +const authRoutes = require('./routes/authRoutes'); +const hostRoutes = require('./routes/hostRoutes'); +const hostGroupRoutes = require('./routes/hostGroupRoutes'); +const packageRoutes = require('./routes/packageRoutes'); +const dashboardRoutes = require('./routes/dashboardRoutes'); +const permissionsRoutes = require('./routes/permissionsRoutes'); +const settingsRoutes = require('./routes/settingsRoutes'); +const dashboardPreferencesRoutes = require('./routes/dashboardPreferencesRoutes'); +const repositoryRoutes = require('./routes/repositoryRoutes'); + +// Initialize Prisma client +const prisma = new PrismaClient(); + +// Initialize logger - only if logging is enabled +const logger = process.env.ENABLE_LOGGING === 'true' ? winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: winston.format.combine( + winston.format.timestamp(), + winston.format.errors({ stack: true }), + winston.format.json() + ), + transports: [ + new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), + new winston.transports.File({ filename: 'logs/combined.log' }), + ], +}) : { + info: () => {}, + error: () => {}, + warn: () => {}, + debug: () => {} +}; + +if (process.env.ENABLE_LOGGING === 'true' && process.env.NODE_ENV !== 'production') { + logger.add(new winston.transports.Console({ + format: winston.format.simple() + })); +} + +const app = express(); +const PORT = process.env.PORT || 3001; + +// Trust proxy (needed when behind reverse proxy) and remove X-Powered-By +if (process.env.TRUST_PROXY) { + app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : parseInt(process.env.TRUST_PROXY, 10) || true); +} else { + app.set('trust proxy', 1); +} +app.disable('x-powered-by'); + +// Rate limiting +const limiter = rateLimit({ + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, + max: parseInt(process.env.RATE_LIMIT_MAX) || 100, + message: 'Too many requests from this IP, please try again later.', +}); + +// Middleware +// Helmet with stricter defaults (CSP/HSTS only in production) +app.use(helmet({ + contentSecurityPolicy: process.env.NODE_ENV === 'production' ? { + useDefaults: true, + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:'], + fontSrc: ["'self'", 'data:'], + connectSrc: ["'self'"], + frameAncestors: ["'none'"], + objectSrc: ["'none'"] + } + } : false, + hsts: process.env.ENABLE_HSTS === 'true' || process.env.NODE_ENV === 'production' +})); + +// CORS allowlist from comma-separated env +const parseOrigins = (val) => (val || '').split(',').map(s => s.trim()).filter(Boolean); +const allowedOrigins = parseOrigins(process.env.CORS_ORIGINS || process.env.CORS_ORIGIN || 'http://localhost:3000'); +app.use(cors({ + origin: function(origin, callback) { + // Allow non-browser/SSR tools with no origin + if (!origin) return callback(null, true); + if (allowedOrigins.includes(origin)) return callback(null, true); + return callback(new Error('Not allowed by CORS')); + }, + credentials: true +})); +app.use(limiter); +// Reduce body size limits to reasonable defaults +app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '5mb' })); +app.use(express.urlencoded({ extended: true, limit: process.env.JSON_BODY_LIMIT || '5mb' })); + +// Request logging - only if logging is enabled +if (process.env.ENABLE_LOGGING === 'true') { + app.use((req, res, next) => { + logger.info(`${req.method} ${req.path} - ${req.ip}`); + next(); + }); +} + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// API routes +const apiVersion = process.env.API_VERSION || 'v1'; + +// Per-route rate limits +const authLimiter = rateLimit({ + windowMs: parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS) || 10 * 60 * 1000, + max: parseInt(process.env.AUTH_RATE_LIMIT_MAX) || 20 +}); +const agentLimiter = rateLimit({ + windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS) || 60 * 1000, + max: parseInt(process.env.AGENT_RATE_LIMIT_MAX) || 120 +}); + +app.use(`/api/${apiVersion}/auth`, authLimiter, authRoutes); +app.use(`/api/${apiVersion}/hosts`, agentLimiter, hostRoutes); +app.use(`/api/${apiVersion}/host-groups`, hostGroupRoutes); +app.use(`/api/${apiVersion}/packages`, packageRoutes); +app.use(`/api/${apiVersion}/dashboard`, dashboardRoutes); +app.use(`/api/${apiVersion}/permissions`, permissionsRoutes); +app.use(`/api/${apiVersion}/settings`, settingsRoutes); +app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes); +app.use(`/api/${apiVersion}/repositories`, repositoryRoutes); + +// Error handling middleware +app.use((err, req, res, next) => { + if (process.env.ENABLE_LOGGING === 'true') { + logger.error(err.stack); + } + res.status(500).json({ + error: 'Something went wrong!', + message: process.env.NODE_ENV === 'development' ? err.message : undefined + }); +}); + +// 404 handler +app.use('*', (req, res) => { + res.status(404).json({ error: 'Route not found' }); +}); + +// Graceful shutdown +process.on('SIGTERM', async () => { + if (process.env.ENABLE_LOGGING === 'true') { + logger.info('SIGTERM received, shutting down gracefully'); + } + await prisma.$disconnect(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + if (process.env.ENABLE_LOGGING === 'true') { + logger.info('SIGINT received, shutting down gracefully'); + } + await prisma.$disconnect(); + process.exit(0); +}); + +// Start server +app.listen(PORT, () => { + if (process.env.ENABLE_LOGGING === 'true') { + logger.info(`Server running on port ${PORT}`); + logger.info(`Environment: ${process.env.NODE_ENV}`); + } +}); + +module.exports = app; \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b51290e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + PatchMon - Linux Patch Monitoring Dashboard + + + + + +
+ + + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4a62453 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,43 @@ +{ + "name": "patchmon-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@tanstack/react-query": "^5.87.4", + "axios": "^1.6.2", + "chart.js": "^4.4.0", + "clsx": "^2.0.0", + "date-fns": "^2.30.0", + "lucide-react": "^0.294.0", + "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.1" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.16", + "eslint": "^8.53.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.4", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "vite": "^7.1.5" + }, + "overrides": { + "esbuild": "^0.24.4" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..387612e --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..0ea9f5a --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,117 @@ +import React from 'react' +import { Routes, Route } from 'react-router-dom' +import { AuthProvider } from './contexts/AuthContext' +import { ThemeProvider } from './contexts/ThemeContext' +import ProtectedRoute from './components/ProtectedRoute' +import Layout from './components/Layout' +import Login from './pages/Login' +import Dashboard from './pages/Dashboard' +import Hosts from './pages/Hosts' +import HostGroups from './pages/HostGroups' +import Packages from './pages/Packages' +import Repositories from './pages/Repositories' +import RepositoryDetail from './pages/RepositoryDetail' +import Users from './pages/Users' +import Permissions from './pages/Permissions' +import Settings from './pages/Settings' +import Profile from './pages/Profile' +import HostDetail from './pages/HostDetail' +import PackageDetail from './pages/PackageDetail' + +function App() { + return ( + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + ) +} + +export default App \ No newline at end of file diff --git a/frontend/src/components/DashboardSettingsModal.jsx b/frontend/src/components/DashboardSettingsModal.jsx new file mode 100644 index 0000000..330b855 --- /dev/null +++ b/frontend/src/components/DashboardSettingsModal.jsx @@ -0,0 +1,306 @@ +import React, { useState, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { + X, + GripVertical, + Eye, + EyeOff, + Save, + RotateCcw, + Settings as SettingsIcon +} from 'lucide-react'; +import { dashboardPreferencesAPI } from '../utils/api'; + +// Sortable Card Item Component +const SortableCardItem = ({ card, onToggle }) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: card.cardId }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+
+ +
+
+ {card.title} +
+
+
+ + +
+ ); +}; + +const DashboardSettingsModal = ({ isOpen, onClose }) => { + const [cards, setCards] = useState([]); + const [hasChanges, setHasChanges] = useState(false); + const queryClient = useQueryClient(); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // Fetch user's dashboard preferences + const { data: preferences, isLoading } = useQuery({ + queryKey: ['dashboardPreferences'], + queryFn: () => dashboardPreferencesAPI.get().then(res => res.data), + enabled: isOpen + }); + + // Fetch default card configuration + const { data: defaultCards } = useQuery({ + queryKey: ['dashboardDefaultCards'], + queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data), + enabled: isOpen + }); + + // Update preferences mutation + const updatePreferencesMutation = useMutation({ + mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences), + onSuccess: (response) => { + // Optimistically update the query cache with the correct data structure + queryClient.setQueryData(['dashboardPreferences'], response.data.preferences); + // Also invalidate to ensure fresh data + queryClient.invalidateQueries(['dashboardPreferences']); + setHasChanges(false); + onClose(); + }, + onError: (error) => { + console.error('Failed to update dashboard preferences:', error); + } + }); + + // Initialize cards when preferences or defaults are loaded + useEffect(() => { + if (preferences && defaultCards) { + // Merge user preferences with default cards + const mergedCards = defaultCards.map(defaultCard => { + const userPreference = preferences.find(p => p.cardId === defaultCard.cardId); + return { + ...defaultCard, + enabled: userPreference ? userPreference.enabled : defaultCard.enabled, + order: userPreference ? userPreference.order : defaultCard.order + }; + }).sort((a, b) => a.order - b.order); + + setCards(mergedCards); + } + }, [preferences, defaultCards]); + + const handleDragEnd = (event) => { + const { active, over } = event; + + if (active.id !== over.id) { + setCards((items) => { + const oldIndex = items.findIndex(item => item.cardId === active.id); + const newIndex = items.findIndex(item => item.cardId === over.id); + + const newItems = arrayMove(items, oldIndex, newIndex); + + // Update order values + return newItems.map((item, index) => ({ + ...item, + order: index + })); + }); + setHasChanges(true); + } + }; + + const handleToggle = (cardId) => { + setCards(prevCards => + prevCards.map(card => + card.cardId === cardId + ? { ...card, enabled: !card.enabled } + : card + ) + ); + setHasChanges(true); + }; + + const handleSave = () => { + const preferences = cards.map(card => ({ + cardId: card.cardId, + enabled: card.enabled, + order: card.order + })); + + updatePreferencesMutation.mutate(preferences); + }; + + const handleReset = () => { + if (defaultCards) { + const resetCards = defaultCards.map(card => ({ + ...card, + enabled: true, + order: card.order + })); + setCards(resetCards); + setHasChanges(true); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+ +
+
+
+
+ +

+ Dashboard Settings +

+
+ +
+ +

+ Customize your dashboard by reordering cards and toggling their visibility. + Drag cards to reorder them, and click the visibility toggle to show/hide cards. +

+ + {isLoading ? ( +
+
+
+ ) : ( + + card.cardId)} strategy={verticalListSortingStrategy}> +
+ {cards.map((card) => ( + + ))} +
+
+
+ )} +
+ +
+ + + + + +
+
+
+
+ ); +}; + +export default DashboardSettingsModal; diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..57593f3 --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -0,0 +1,439 @@ +import React from 'react' +import { Link, useLocation } from 'react-router-dom' +import { + Home, + Server, + Package, + Shield, + BarChart3, + Menu, + X, + LogOut, + User, + Users, + Settings, + UserCircle, + ChevronLeft, + ChevronRight, + Clock, + RefreshCw, + GitBranch, + Wrench +} from 'lucide-react' +import { useState, useEffect, useRef } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useAuth } from '../contexts/AuthContext' +import { dashboardAPI, formatRelativeTime } from '../utils/api' + +const Layout = ({ children }) => { + const [sidebarOpen, setSidebarOpen] = useState(false) + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { + // Load sidebar state from localStorage, default to false + const saved = localStorage.getItem('sidebarCollapsed') + return saved ? JSON.parse(saved) : false + }) + const [userMenuOpen, setUserMenuOpen] = useState(false) + const location = useLocation() + const { user, logout, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canManageSettings } = useAuth() + const userMenuRef = useRef(null) + + // Fetch dashboard stats for the "Last updated" info + const { data: stats, refetch } = useQuery({ + queryKey: ['dashboardStats'], + queryFn: () => dashboardAPI.getStats().then(res => res.data), + refetchInterval: 60000, // Refresh every minute + staleTime: 30000, // Consider data stale after 30 seconds + }) + + const navigation = [ + { name: 'Dashboard', href: '/', icon: Home }, + { + section: 'Inventory', + items: [ + ...(canViewHosts() ? [{ name: 'Hosts', href: '/hosts', icon: Server }] : []), + ...(canManageHosts() ? [{ name: 'Host Groups', href: '/host-groups', icon: Users }] : []), + ...(canViewPackages() ? [{ name: 'Packages', href: '/packages', icon: Package }] : []), + ...(canViewHosts() ? [{ name: 'Repos', href: '/repositories', icon: GitBranch }] : []), + { name: 'Services', href: '/services', icon: Wrench, comingSoon: true }, + { name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true }, + ] + }, + { + section: 'Users', + items: [ + ...(canViewUsers() ? [{ name: 'Users', href: '/users', icon: Users }] : []), + ...(canManageSettings() ? [{ name: 'Permissions', href: '/permissions', icon: Shield }] : []), + ] + }, + { + section: 'Settings', + items: [ + ...(canManageSettings() ? [{ name: 'Settings', href: '/settings', icon: Settings }] : []), + ] + } + ] + + const isActive = (path) => location.pathname === path + + // Get page title based on current route + const getPageTitle = () => { + const path = location.pathname + + if (path === '/') return 'Dashboard' + if (path === '/hosts') return 'Hosts' + if (path === '/host-groups') return 'Host Groups' + if (path === '/packages') return 'Packages' + if (path === '/repositories' || path.startsWith('/repositories/')) return 'Repositories' + if (path === '/services') return 'Services' + if (path === '/users') return 'Users' + if (path === '/permissions') return 'Permissions' + if (path === '/settings') return 'Settings' + if (path === '/profile') return 'My Profile' + if (path.startsWith('/hosts/')) return 'Host Details' + if (path.startsWith('/packages/')) return 'Package Details' + + return 'PatchMon' + } + + const handleLogout = async () => { + await logout() + setUserMenuOpen(false) + } + + // Save sidebar collapsed state to localStorage + useEffect(() => { + localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed)) + }, [sidebarCollapsed]) + + // Close user menu when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (userMenuRef.current && !userMenuRef.current.contains(event.target)) { + setUserMenuOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, []) + + return ( +
+ {/* Mobile sidebar */} +
+
setSidebarOpen(false)} /> +
+
+ +
+
+
+ +

PatchMon

+
+
+ +
+
+ + {/* Desktop sidebar */} +
+
+
+ {sidebarCollapsed ? ( + + ) : ( + <> +
+ +

PatchMon

+
+ + + )} +
+ + + {/* Profile Section - Bottom of Sidebar */} +
+ {!sidebarCollapsed ? ( +
+ {/* My Profile Link */} + + + My Profile + + + {/* User Info with Sign Out */} +
+
+
+

+ {user?.username} +

+ {user?.role === 'admin' && ( + + Admin + + )} +
+

+ {user?.email} +

+
+ +
+
+ ) : ( +
+ + + + +
+ )} +
+
+
+ + {/* Main content */} +
+ {/* Top bar */} +
+ + + {/* Separator */} +
+ +
+
+

+ {getPageTitle()} +

+
+
+ + {/* Last updated info */} + {stats && ( +
+ + Last updated: {formatRelativeTime(stats.lastUpdated)} + +
+ )} + + {/* Customize Dashboard Button - Only show on Dashboard page */} + {location.pathname === '/' && ( + + )} +
+
+
+ +
+
+ {children} +
+
+
+
+ ) +} + +export default Layout \ No newline at end of file diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..df3fb5c --- /dev/null +++ b/frontend/src/components/ProtectedRoute.jsx @@ -0,0 +1,47 @@ +import React from 'react' +import { Navigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' + +const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = null }) => { + const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth() + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (!isAuthenticated()) { + return + } + + // Check admin requirement + if (requireAdmin && !isAdmin()) { + return ( +
+
+

Access Denied

+

You don't have permission to access this page.

+
+
+ ) + } + + // Check specific permission requirement + if (requirePermission && !hasPermission(requirePermission)) { + return ( +
+
+

Access Denied

+

You don't have permission to access this page.

+
+
+ ) + } + + return children +} + +export default ProtectedRoute diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx new file mode 100644 index 0000000..11c5d7f --- /dev/null +++ b/frontend/src/contexts/AuthContext.jsx @@ -0,0 +1,246 @@ +import React, { createContext, useContext, useState, useEffect } from 'react' + +const AuthContext = createContext() + +export const useAuth = () => { + const context = useContext(AuthContext) + if (!context) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} + +export const AuthProvider = ({ children }) => { + const [user, setUser] = useState(null) + const [token, setToken] = useState(null) + const [permissions, setPermissions] = useState(null) + const [isLoading, setIsLoading] = useState(true) + + // Initialize auth state from localStorage + useEffect(() => { + const storedToken = localStorage.getItem('token') + const storedUser = localStorage.getItem('user') + const storedPermissions = localStorage.getItem('permissions') + + if (storedToken && storedUser) { + try { + setToken(storedToken) + setUser(JSON.parse(storedUser)) + if (storedPermissions) { + setPermissions(JSON.parse(storedPermissions)) + } else { + // Fetch permissions if not stored + fetchPermissions(storedToken) + } + } catch (error) { + console.error('Error parsing stored user data:', error) + localStorage.removeItem('token') + localStorage.removeItem('user') + localStorage.removeItem('permissions') + } + } + setIsLoading(false) + }, []) + + // Periodically refresh permissions when user is logged in + useEffect(() => { + if (token && user) { + // Refresh permissions every 30 seconds + const interval = setInterval(() => { + refreshPermissions() + }, 30000) + + return () => clearInterval(interval) + } + }, [token, user]) + + const fetchPermissions = async (authToken) => { + try { + const response = await fetch('/api/v1/permissions/user-permissions', { + headers: { + 'Authorization': `Bearer ${authToken}`, + }, + }) + + if (response.ok) { + const data = await response.json() + setPermissions(data) + localStorage.setItem('permissions', JSON.stringify(data)) + return data + } else { + console.error('Failed to fetch permissions') + return null + } + } catch (error) { + console.error('Error fetching permissions:', error) + return null + } + } + + const refreshPermissions = async () => { + if (token) { + const updatedPermissions = await fetchPermissions(token) + return updatedPermissions + } + return null + } + + const login = async (username, password) => { + try { + const response = await fetch('/api/v1/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }), + }) + + const data = await response.json() + + if (response.ok) { + setToken(data.token) + setUser(data.user) + localStorage.setItem('token', data.token) + localStorage.setItem('user', JSON.stringify(data.user)) + + // Fetch user permissions after successful login + const userPermissions = await fetchPermissions(data.token) + if (userPermissions) { + setPermissions(userPermissions) + } + + return { success: true } + } else { + return { success: false, error: data.error || 'Login failed' } + } + } catch (error) { + return { success: false, error: 'Network error occurred' } + } + } + + const logout = async () => { + try { + if (token) { + await fetch('/api/v1/auth/logout', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }) + } + } catch (error) { + console.error('Logout error:', error) + } finally { + setToken(null) + setUser(null) + setPermissions(null) + localStorage.removeItem('token') + localStorage.removeItem('user') + localStorage.removeItem('permissions') + } + } + + const updateProfile = async (profileData) => { + try { + const response = await fetch('/api/v1/auth/profile', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(profileData), + }) + + const data = await response.json() + + if (response.ok) { + setUser(data.user) + localStorage.setItem('user', JSON.stringify(data.user)) + return { success: true, user: data.user } + } else { + return { success: false, error: data.error || 'Update failed' } + } + } catch (error) { + return { success: false, error: 'Network error occurred' } + } + } + + const changePassword = async (currentPassword, newPassword) => { + try { + const response = await fetch('/api/v1/auth/change-password', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ currentPassword, newPassword }), + }) + + const data = await response.json() + + if (response.ok) { + return { success: true } + } else { + return { success: false, error: data.error || 'Password change failed' } + } + } catch (error) { + return { success: false, error: 'Network error occurred' } + } + } + + const isAuthenticated = () => { + return !!(token && user) + } + + const isAdmin = () => { + return user?.role === 'admin' + } + + // Permission checking functions + const hasPermission = (permission) => { + return permissions?.[permission] === true + } + + const canViewDashboard = () => hasPermission('canViewDashboard') + const canViewHosts = () => hasPermission('canViewHosts') + const canManageHosts = () => hasPermission('canManageHosts') + const canViewPackages = () => hasPermission('canViewPackages') + const canManagePackages = () => hasPermission('canManagePackages') + const canViewUsers = () => hasPermission('canViewUsers') + const canManageUsers = () => hasPermission('canManageUsers') + const canViewReports = () => hasPermission('canViewReports') + const canExportData = () => hasPermission('canExportData') + const canManageSettings = () => hasPermission('canManageSettings') + + const value = { + user, + token, + permissions, + isLoading, + login, + logout, + updateProfile, + changePassword, + refreshPermissions, + isAuthenticated, + isAdmin, + hasPermission, + canViewDashboard, + canViewHosts, + canManageHosts, + canViewPackages, + canManagePackages, + canViewUsers, + canManageUsers, + canViewReports, + canExportData, + canManageSettings + } + + return ( + + {children} + + ) +} diff --git a/frontend/src/contexts/ThemeContext.jsx b/frontend/src/contexts/ThemeContext.jsx new file mode 100644 index 0000000..a289b3c --- /dev/null +++ b/frontend/src/contexts/ThemeContext.jsx @@ -0,0 +1,54 @@ +import React, { createContext, useContext, useEffect, useState } from 'react' + +const ThemeContext = createContext() + +export const useTheme = () => { + const context = useContext(ThemeContext) + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider') + } + return context +} + +export const ThemeProvider = ({ children }) => { + const [theme, setTheme] = useState(() => { + // Check localStorage first, then system preference + const savedTheme = localStorage.getItem('theme') + if (savedTheme) { + return savedTheme + } + // Check system preference + if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark' + } + return 'light' + }) + + useEffect(() => { + // Apply theme to document + if (theme === 'dark') { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } + + // Save to localStorage + localStorage.setItem('theme', theme) + }, [theme]) + + const toggleTheme = () => { + setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light') + } + + const value = { + theme, + toggleTheme, + isDark: theme === 'dark' + } + + return ( + + {children} + + ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..43fafdb --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,127 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + font-family: Inter, ui-sans-serif, system-ui; + } + + body { + @apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased; + } +} + +@layer components { + .btn { + @apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-150; + } + + .btn-primary { + @apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500; + } + + .btn-secondary { + @apply btn bg-secondary-600 text-white hover:bg-secondary-700 focus:ring-secondary-500; + } + + .btn-success { + @apply btn bg-success-600 text-white hover:bg-success-700 focus:ring-success-500; + } + + .btn-warning { + @apply btn bg-warning-600 text-white hover:bg-warning-700 focus:ring-warning-500; + } + + .btn-danger { + @apply btn bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500; + } + + .btn-outline { + @apply btn border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-secondary-500; + } + + .card { + @apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600; + } + + .card-hover { + @apply card hover:shadow-card-hover transition-shadow duration-150; + } + + .input { + @apply block w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100; + } + + .label { + @apply block text-sm font-medium text-secondary-700 dark:text-secondary-200; + } + + .badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + } + + .badge-primary { + @apply badge bg-primary-100 text-primary-800; + } + + .badge-secondary { + @apply badge bg-secondary-100 text-secondary-800; + } + + .badge-success { + @apply badge bg-success-100 text-success-800; + } + + .badge-warning { + @apply badge bg-warning-100 text-warning-800; + } + + .badge-danger { + @apply badge bg-danger-100 text-danger-800; + } +} + +@layer utilities { + .text-shadow { + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + } + + .scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: #cbd5e1 #f1f5f9; + } + + .dark .scrollbar-thin { + scrollbar-color: #64748b #475569; + } + + .scrollbar-thin::-webkit-scrollbar { + width: 6px; + } + + .scrollbar-thin::-webkit-scrollbar-track { + background: #f1f5f9; + } + + .dark .scrollbar-thin::-webkit-scrollbar-track { + background: #475569; + } + + .scrollbar-thin::-webkit-scrollbar-thumb { + background-color: #cbd5e1; + border-radius: 3px; + } + + .dark .scrollbar-thin::-webkit-scrollbar-thumb { + background-color: #64748b; + } + + .scrollbar-thin::-webkit-scrollbar-thumb:hover { + background-color: #94a3b8; + } + + .dark .scrollbar-thin::-webkit-scrollbar-thumb:hover { + background-color: #94a3b8; + } +} \ No newline at end of file diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..a1e2515 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,27 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import App from './App.jsx' +import './index.css' + +// Create a client for React Query +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + staleTime: 5 * 60 * 1000, // 5 minutes + }, + }, +}) + +ReactDOM.createRoot(document.getElementById('root')).render( + + + + + + + , +) \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..be63256 --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -0,0 +1,460 @@ +import React, { useState, useEffect } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { + Server, + Package, + AlertTriangle, + Shield, + TrendingUp, + RefreshCw, + Clock +} from 'lucide-react' +import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js' +import { Pie, Bar } from 'react-chartjs-2' +import { dashboardAPI, dashboardPreferencesAPI, formatRelativeTime } from '../utils/api' +import DashboardSettingsModal from '../components/DashboardSettingsModal' +import { useTheme } from '../contexts/ThemeContext' + +// Register Chart.js components +ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title) + +const Dashboard = () => { + const [showSettingsModal, setShowSettingsModal] = useState(false) + const [cardPreferences, setCardPreferences] = useState([]) + const navigate = useNavigate() + const { isDark } = useTheme() + + // Navigation handlers + const handleTotalHostsClick = () => { + navigate('/hosts') + } + + const handleHostsNeedingUpdatesClick = () => { + navigate('/hosts?filter=needsUpdates') + } + + const handleOutdatedPackagesClick = () => { + navigate('/packages?filter=outdated') + } + + const handleSecurityUpdatesClick = () => { + navigate('/packages?filter=security') + } + + const { data: stats, isLoading, error, refetch } = useQuery({ + queryKey: ['dashboardStats'], + queryFn: () => dashboardAPI.getStats().then(res => res.data), + refetchInterval: 60000, // Refresh every minute + staleTime: 30000, // Consider data stale after 30 seconds + }) + + // Fetch user's dashboard preferences + const { data: preferences, refetch: refetchPreferences } = useQuery({ + queryKey: ['dashboardPreferences'], + queryFn: () => dashboardPreferencesAPI.get().then(res => res.data), + staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes + }) + + // Fetch default card configuration + const { data: defaultCards } = useQuery({ + queryKey: ['dashboardDefaultCards'], + queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data), + }) + + // Merge preferences with default cards + useEffect(() => { + if (preferences && defaultCards) { + const mergedCards = defaultCards.map(defaultCard => { + const userPreference = preferences.find(p => p.cardId === defaultCard.cardId); + return { + ...defaultCard, + enabled: userPreference ? userPreference.enabled : defaultCard.enabled, + order: userPreference ? userPreference.order : defaultCard.order + }; + }).sort((a, b) => a.order - b.order); + + setCardPreferences(mergedCards); + } else if (defaultCards) { + // If no preferences exist, use defaults + setCardPreferences(defaultCards.sort((a, b) => a.order - b.order)); + } + }, [preferences, defaultCards]) + + // Listen for custom event from Layout component + useEffect(() => { + const handleOpenSettings = () => { + setShowSettingsModal(true); + }; + + window.addEventListener('openDashboardSettings', handleOpenSettings); + return () => { + window.removeEventListener('openDashboardSettings', handleOpenSettings); + }; + }, []) + + // Helper function to check if a card should be displayed + const isCardEnabled = (cardId) => { + const card = cardPreferences.find(c => c.cardId === cardId); + return card ? card.enabled : true; // Default to enabled if not found + } + + // Helper function to get card type for layout grouping + const getCardType = (cardId) => { + if (['totalHosts', 'hostsNeedingUpdates', 'totalOutdatedPackages', 'securityUpdates'].includes(cardId)) { + return 'stats'; + } else if (['osDistribution', 'updateStatus', 'packagePriority'].includes(cardId)) { + return 'charts'; + } else if (['erroredHosts', 'quickStats'].includes(cardId)) { + return 'fullwidth'; + } + return 'fullwidth'; // Default to full width + } + + // Helper function to get CSS class for card group + const getGroupClassName = (cardType) => { + switch (cardType) { + case 'stats': + return 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4'; + case 'charts': + return 'grid grid-cols-1 lg:grid-cols-3 gap-6'; + case 'fullwidth': + return 'space-y-6'; + default: + return 'space-y-6'; + } + } + + // Helper function to render a card by ID + const renderCard = (cardId) => { + switch (cardId) { + case 'totalHosts': + return ( +
+
+
+ +
+
+

Total Hosts

+

+ {stats.cards.totalHosts} +

+
+
+
+ ); + + case 'hostsNeedingUpdates': + return ( +
+
+
+ +
+
+

Needs Updating

+

+ {stats.cards.hostsNeedingUpdates} +

+
+
+
+ ); + + case 'totalOutdatedPackages': + return ( +
+
+
+ +
+
+

Outdated Packages

+

+ {stats.cards.totalOutdatedPackages} +

+
+
+
+ ); + + case 'securityUpdates': + return ( +
+
+
+ +
+
+

Security Updates

+

+ {stats.cards.securityUpdates} +

+
+
+
+ ); + + case 'erroredHosts': + return ( +
0 + ? 'bg-danger-50 border-danger-200' + : 'bg-success-50 border-success-200' + }`}> +
+ 0 ? 'text-danger-400' : 'text-success-400' + }`} /> +
+ {stats.cards.erroredHosts > 0 ? ( + <> +

+ {stats.cards.erroredHosts} host{stats.cards.erroredHosts > 1 ? 's' : ''} haven't reported in 24+ hours +

+

+ These hosts may be offline or experiencing connectivity issues. +

+ + ) : ( + <> +

+ All hosts are reporting normally +

+

+ No hosts have failed to report in the last 24 hours. +

+ + )} +
+
+
+ ); + + case 'osDistribution': + return ( +
+

OS Distribution

+
+ +
+
+ ); + + case 'updateStatus': + return ( +
+

Update Status

+
+ +
+
+ ); + + case 'packagePriority': + return ( +
+

Package Priority

+
+ +
+
+ ); + + case 'quickStats': + return ( +
+
+

Quick Stats

+ +
+
+
+
+ {((stats.cards.hostsNeedingUpdates / stats.cards.totalHosts) * 100).toFixed(1)}% +
+
Hosts need updates
+
+
+
+ {stats.cards.securityUpdates} +
+
Security updates pending
+
+
+
+ {stats.cards.totalHosts - stats.cards.erroredHosts} +
+
Hosts online
+
+
+
+ ); + + default: + return null; + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+
+ +
+

Error loading dashboard

+

+ {error.message || 'Failed to load dashboard statistics'} +

+ +
+
+
+ ) + } + + const chartOptions = { + responsive: true, + plugins: { + legend: { + position: 'bottom', + labels: { + color: isDark ? '#ffffff' : '#374151', + font: { + size: 12 + } + } + }, + }, + } + + const osChartData = { + labels: stats.charts.osDistribution.map(item => item.name), + datasets: [ + { + data: stats.charts.osDistribution.map(item => item.count), + backgroundColor: [ + '#3B82F6', // Blue + '#10B981', // Green + '#F59E0B', // Yellow + '#EF4444', // Red + '#8B5CF6', // Purple + '#06B6D4', // Cyan + ], + borderWidth: 2, + borderColor: '#ffffff', + }, + ], + } + + const updateStatusChartData = { + labels: stats.charts.updateStatusDistribution.map(item => item.name), + datasets: [ + { + data: stats.charts.updateStatusDistribution.map(item => item.count), + backgroundColor: [ + '#10B981', // Green - Up to date + '#F59E0B', // Yellow - Needs updates + '#EF4444', // Red - Errored + ], + borderWidth: 2, + borderColor: '#ffffff', + }, + ], + } + + const packagePriorityChartData = { + labels: stats.charts.packageUpdateDistribution.map(item => item.name), + datasets: [ + { + data: stats.charts.packageUpdateDistribution.map(item => item.count), + backgroundColor: [ + '#EF4444', // Red - Security + '#3B82F6', // Blue - Regular + ], + borderWidth: 2, + borderColor: '#ffffff', + }, + ], + } + + return ( +
+ + {/* Dynamically Rendered Cards - Unified Order */} + {(() => { + const enabledCards = cardPreferences + .filter(card => isCardEnabled(card.cardId)) + .sort((a, b) => a.order - b.order); + + // Group consecutive cards of the same type for proper layout + const cardGroups = []; + let currentGroup = null; + + enabledCards.forEach(card => { + const cardType = getCardType(card.cardId); + + if (!currentGroup || currentGroup.type !== cardType) { + // Start a new group + currentGroup = { + type: cardType, + cards: [card] + }; + cardGroups.push(currentGroup); + } else { + // Add to existing group + currentGroup.cards.push(card); + } + }); + + return ( + <> + {cardGroups.map((group, groupIndex) => ( +
+ {group.cards.map(card => ( +
+ {renderCard(card.cardId)} +
+ ))} +
+ ))} + + ); + })()} + + {/* Dashboard Settings Modal */} + setShowSettingsModal(false)} + /> +
+ ) +} + +export default Dashboard \ No newline at end of file diff --git a/frontend/src/pages/HostDetail.jsx b/frontend/src/pages/HostDetail.jsx new file mode 100644 index 0000000..27dfdaa --- /dev/null +++ b/frontend/src/pages/HostDetail.jsx @@ -0,0 +1,732 @@ +import React, { useState } from 'react' +import { useParams, Link, useNavigate } from 'react-router-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + Server, + ArrowLeft, + Package, + Shield, + Clock, + CheckCircle, + AlertTriangle, + RefreshCw, + Calendar, + Monitor, + HardDrive, + Key, + Trash2, + X, + Copy, + Eye, + Code, + EyeOff, + ToggleLeft, + ToggleRight +} from 'lucide-react' +import { dashboardAPI, adminHostsAPI, settingsAPI, formatRelativeTime, formatDate } from '../utils/api' + +const HostDetail = () => { + const { hostId } = useParams() + const navigate = useNavigate() + const queryClient = useQueryClient() + const [showCredentialsModal, setShowCredentialsModal] = useState(false) + const [showDeleteModal, setShowDeleteModal] = useState(false) + + const { data: host, isLoading, error, refetch } = useQuery({ + queryKey: ['host', hostId], + queryFn: () => dashboardAPI.getHostDetail(hostId).then(res => res.data), + refetchInterval: 60000, + staleTime: 30000, + }) + + const deleteHostMutation = useMutation({ + mutationFn: (hostId) => adminHostsAPI.delete(hostId), + onSuccess: () => { + queryClient.invalidateQueries(['hosts']) + navigate('/hosts') + }, + }) + + // Toggle auto-update mutation + const toggleAutoUpdateMutation = useMutation({ + mutationFn: (autoUpdate) => adminHostsAPI.toggleAutoUpdate(hostId, autoUpdate).then(res => res.data), + onSuccess: () => { + queryClient.invalidateQueries(['host', hostId]) + queryClient.invalidateQueries(['hosts']) + } + }) + + const handleDeleteHost = async () => { + if (window.confirm(`Are you sure you want to delete host "${host.hostname}"? This action cannot be undone.`)) { + try { + await deleteHostMutation.mutateAsync(hostId) + } catch (error) { + console.error('Failed to delete host:', error) + alert('Failed to delete host') + } + } + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+
+
+ + + +
+
+ +
+
+ +
+

Error loading host

+

+ {error.message || 'Failed to load host details'} +

+ +
+
+
+
+ ) + } + + if (!host) { + return ( +
+
+
+ + + +
+
+ +
+ +

Host Not Found

+

+ The requested host could not be found. +

+
+
+ ) + } + + const getStatusColor = (isStale, needsUpdate) => { + if (isStale) return 'text-danger-600' + if (needsUpdate) return 'text-warning-600' + return 'text-success-600' + } + + const getStatusIcon = (isStale, needsUpdate) => { + if (isStale) return + if (needsUpdate) return + return + } + + const getStatusText = (isStale, needsUpdate) => { + if (isStale) return 'Stale' + if (needsUpdate) return 'Needs Updates' + return 'Up to Date' + } + + const isStale = new Date() - new Date(host.lastUpdate) > 24 * 60 * 60 * 1000 + + return ( +
+ {/* Header */} +
+
+ + + +

{host.hostname}

+
+
+ + +
+
+ + {/* Host Information */} +
+ {/* Basic Info */} +
+

Host Information

+
+
+ +
+

Hostname

+

{host.hostname}

+
+
+ +
+ +
+

Host Group

+ {host.hostGroup ? ( + + {host.hostGroup.name} + + ) : ( + + Ungrouped + + )} +
+
+ +
+ +
+

Operating System

+

{host.osType} {host.osVersion}

+
+
+ + {host.ip && ( +
+ +
+

IP Address

+

{host.ip}

+
+
+ )} + + {host.architecture && ( +
+ +
+

Architecture

+

{host.architecture}

+
+
+ )} + +
+ +
+

Last Update

+

{formatRelativeTime(host.lastUpdate)}

+
+
+ + {host.agentVersion && ( +
+
+ +
+

Agent Version

+

{host.agentVersion}

+
+
+ + {/* Auto-Update Toggle */} +
+ Auto-update + +
+
+ )} +
+
+ + {/* Statistics */} +
+

Statistics

+
+
+
+ +
+

{host.stats.totalPackages}

+

Total Packages

+
+ +
+
+ +
+

{host.stats.outdatedPackages}

+

Outdated

+
+ +
+
+ +
+

{host.stats.securityUpdates}

+

Security Updates

+
+
+ + {/* Status */} +
+
0)}`}> + {getStatusIcon(isStale, host.stats.outdatedPackages > 0)} + {getStatusText(isStale, host.stats.outdatedPackages > 0)} +
+
+
+
+ + {/* Packages */} +
+
+

Packages

+
+ +
+ + + + + + + + + + + {host.hostPackages?.map((hostPackage) => ( + + + + + + + ))} + +
+ Package + + Current Version + + Available Version + + Status +
+
+ +
+
+ {hostPackage.package.name} +
+ {hostPackage.package.description && ( +
+ {hostPackage.package.description} +
+ )} +
+
+
+ {hostPackage.currentVersion} + + {hostPackage.availableVersion || '-'} + + {hostPackage.needsUpdate ? ( +
+ + {hostPackage.isSecurityUpdate ? 'Security Update' : 'Update Available'} + + {hostPackage.isSecurityUpdate && ( + + )} +
+ ) : ( + Up to date + )} +
+
+ + {host.hostPackages?.length === 0 && ( +
+ +

No packages found

+
+ )} +
+ + {/* Update History */} +
+
+

Update History

+
+ +
+ {host.updateHistory?.length > 0 ? ( +
+ {host.updateHistory.map((update, index) => ( +
+
+
+
+

+ {update.status === 'success' ? 'Update Successful' : 'Update Failed'} +

+

+ {formatDate(update.timestamp)} +

+
+
+
+

+ {update.packagesCount} packages +

+ {update.securityCount > 0 && ( +

+ {update.securityCount} security updates +

+ )} +
+
+ ))} +
+ ) : ( +
+ +

No update history available

+
+ )} +
+
+ + {/* Credentials Modal */} + {showCredentialsModal && ( + setShowCredentialsModal(false)} + /> + )} + + {/* Delete Confirmation Modal */} + {showDeleteModal && ( + setShowDeleteModal(false)} + onConfirm={handleDeleteHost} + isLoading={deleteHostMutation.isPending} + /> + )} +
+ ) +} + +// Credentials Modal Component +const CredentialsModal = ({ host, isOpen, onClose }) => { + const [showApiKey, setShowApiKey] = useState(false) + const [activeTab, setActiveTab] = useState('credentials') + + const { data: serverUrlData } = useQuery({ + queryKey: ['serverUrl'], + queryFn: () => settingsAPI.getServerUrl().then(res => res.data), + }) + + const serverUrl = serverUrlData?.serverUrl || 'http://localhost:3001' + + const copyToClipboard = (text) => { + navigator.clipboard.writeText(text) + } + + const getSetupCommands = () => { + return `# Run this on the target host: ${host?.hostname} + +echo "🔄 Setting up PatchMon agent..." + +# Download and install agent +echo "📥 Downloading agent script..." +curl -o /tmp/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download +sudo mkdir -p /etc/patchmon +sudo mv /tmp/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh +sudo chmod +x /usr/local/bin/patchmon-agent.sh + +# Configure credentials +echo "🔑 Configuring API credentials..." +sudo /usr/local/bin/patchmon-agent.sh configure "${host?.apiId}" "${host?.apiKey}" + +# Test configuration +echo "🧪 Testing configuration..." +sudo /usr/local/bin/patchmon-agent.sh test + +# Send initial update +echo "📊 Sending initial package data..." +sudo /usr/local/bin/patchmon-agent.sh update + +# Setup crontab +echo "⏰ Setting up hourly crontab..." +echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab - + +echo "✅ PatchMon agent setup complete!" +echo " - Agent installed: /usr/local/bin/patchmon-agent.sh" +echo " - Config directory: /etc/patchmon/" +echo " - Updates: Every hour via crontab" +echo " - View logs: tail -f /var/log/patchmon-agent.log"` + } + + if (!isOpen || !host) return null + + const commands = getSetupCommands() + + return ( +
+
+
+

Host Setup - {host.hostname}

+ +
+ + {/* Tabs */} +
+ +
+ + {/* Tab Content */} + {activeTab === 'credentials' && ( +
+
+

API Credentials

+
+
+ +
+ + +
+
+ +
+ +
+ + + +
+
+
+
+ +
+
+ +
+

Security Notice

+

+ Keep these credentials secure. They provide full access to this host's monitoring data. +

+
+
+
+
+ )} + + {activeTab === 'quick-install' && ( +
+
+

One-Line Installation

+

+ Copy and run this command on the target host to automatically install and configure the PatchMon agent: +

+
+ + +
+
+ +
+

Manual Installation

+

+ If you prefer manual installation, run these commands on the target host: +

+
+                {commands}
+              
+ +
+
+ )} + +
+ +
+
+
+ ) +} + +// Delete Confirmation Modal Component +const DeleteConfirmationModal = ({ host, isOpen, onClose, onConfirm, isLoading }) => { + if (!isOpen || !host) return null + + return ( +
+
+
+
+ +
+
+

+ Delete Host +

+

+ This action cannot be undone +

+
+
+ +
+

+ Are you sure you want to delete the host{' '} + "{host.hostname}"? +

+
+

+ Warning: This will permanently remove the host and all its associated data, + including package information and update history. +

+
+
+ +
+ + +
+
+
+ ) +} + +export default HostDetail + + \ No newline at end of file diff --git a/frontend/src/pages/HostGroups.jsx b/frontend/src/pages/HostGroups.jsx new file mode 100644 index 0000000..1f4568c --- /dev/null +++ b/frontend/src/pages/HostGroups.jsx @@ -0,0 +1,498 @@ +import React, { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { + Plus, + Edit, + Trash2, + Server, + Users, + AlertTriangle, + CheckCircle +} from 'lucide-react' +import { hostGroupsAPI } from '../utils/api' + +const HostGroups = () => { + const [showCreateModal, setShowCreateModal] = useState(false) + const [showEditModal, setShowEditModal] = useState(false) + const [selectedGroup, setSelectedGroup] = useState(null) + const [showDeleteModal, setShowDeleteModal] = useState(false) + const [groupToDelete, setGroupToDelete] = useState(null) + + const queryClient = useQueryClient() + + // Fetch host groups + const { data: hostGroups, isLoading, error } = useQuery({ + queryKey: ['hostGroups'], + queryFn: () => hostGroupsAPI.list().then(res => res.data), + }) + + // Create host group mutation + const createMutation = useMutation({ + mutationFn: (data) => hostGroupsAPI.create(data), + onSuccess: () => { + queryClient.invalidateQueries(['hostGroups']) + setShowCreateModal(false) + }, + onError: (error) => { + console.error('Failed to create host group:', error) + } + }) + + // Update host group mutation + const updateMutation = useMutation({ + mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries(['hostGroups']) + setShowEditModal(false) + setSelectedGroup(null) + }, + onError: (error) => { + console.error('Failed to update host group:', error) + } + }) + + // Delete host group mutation + const deleteMutation = useMutation({ + mutationFn: (id) => hostGroupsAPI.delete(id), + onSuccess: () => { + queryClient.invalidateQueries(['hostGroups']) + setShowDeleteModal(false) + setGroupToDelete(null) + }, + onError: (error) => { + console.error('Failed to delete host group:', error) + } + }) + + const handleCreate = (data) => { + createMutation.mutate(data) + } + + const handleEdit = (group) => { + setSelectedGroup(group) + setShowEditModal(true) + } + + const handleUpdate = (data) => { + updateMutation.mutate({ id: selectedGroup.id, data }) + } + + const handleDeleteClick = (group) => { + setGroupToDelete(group) + setShowDeleteModal(true) + } + + const handleDeleteConfirm = () => { + deleteMutation.mutate(groupToDelete.id) + } + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (error) { + return ( +
+
+ +
+

+ Error loading host groups +

+

+ {error.message || 'Failed to load host groups'} +

+
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

+ Organize your hosts into logical groups for better management +

+
+ +
+ + {/* Host Groups Grid */} + {hostGroups && hostGroups.length > 0 ? ( +
+ {hostGroups.map((group) => ( +
+
+
+
+
+

+ {group.name} +

+ {group.description && ( +

+ {group.description} +

+ )} +
+
+
+ + +
+
+ +
+
+ + {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''} +
+
+
+ ))} +
+ ) : ( +
+ +

+ No host groups yet +

+

+ Create your first host group to organize your hosts +

+ +
+ )} + + {/* Create Modal */} + {showCreateModal && ( + setShowCreateModal(false)} + onSubmit={handleCreate} + isLoading={createMutation.isPending} + /> + )} + + {/* Edit Modal */} + {showEditModal && selectedGroup && ( + { + setShowEditModal(false) + setSelectedGroup(null) + }} + onSubmit={handleUpdate} + isLoading={updateMutation.isPending} + /> + )} + + {/* Delete Confirmation Modal */} + {showDeleteModal && groupToDelete && ( + { + setShowDeleteModal(false) + setGroupToDelete(null) + }} + onConfirm={handleDeleteConfirm} + isLoading={deleteMutation.isPending} + /> + )} +
+ ) +} + +// Create Host Group Modal +const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => { + const [formData, setFormData] = useState({ + name: '', + description: '', + color: '#3B82F6' + }) + + const handleSubmit = (e) => { + e.preventDefault() + onSubmit(formData) + } + + const handleChange = (e) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value + }) + } + + return ( +
+
+

+ Create Host Group +

+ +
+
+ + +
+ +
+ +