From f42c6cc185104c6751190da574af72a0285e56a1 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Wed, 17 Sep 2025 22:07:30 +0100 Subject: [PATCH] feat: Add Server Version management with GitHub integration - Add Server Version tab in settings - Add githubRepoUrl field to Settings model - Add database migration for github_repo_url - Update settings API to handle GitHub repo URL - Add version checking UI with current/latest version display - Default GitHub repo: git@github.com:9technologygroup/patchmon.net.git --- .gitignore | 3 + README.md | 434 ----- .../migration.sql | 2 + backend/prisma/schema.prisma | 1 + backend/src/routes/authRoutes.js | 61 + backend/src/routes/dashboardRoutes.js | 31 +- backend/src/routes/settingsRoutes.js | 16 +- frontend/src/components/Layout.jsx | 297 ++- frontend/src/pages/Dashboard.jsx | 82 +- frontend/src/pages/HostDetail.jsx | 13 +- frontend/src/pages/Hosts.jsx | 249 ++- frontend/src/pages/Settings.jsx | 103 +- frontend/src/pages/Users.jsx | 161 +- frontend/src/utils/api.js | 3 +- manage-patchmon.sh | 1591 +++++++++++++++++ setup-admin-user.js | 12 +- 16 files changed, 2442 insertions(+), 617 deletions(-) delete mode 100644 README.md create mode 100644 backend/prisma/migrations/20250917210606_add_github_repo_url/migration.sql create mode 100755 manage-patchmon.sh diff --git a/.gitignore b/.gitignore index da7d27c..662952d 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,6 @@ test-results.xml # Deployment scripts (production deployment tools) deploy-patchmon.sh manage-instances.sh +setup-installer-site.sh +install-server.* +notify-clients-upgrade.sh diff --git a/README.md b/README.md deleted file mode 100644 index 0b533d9..0000000 --- a/README.md +++ /dev/null @@ -1,434 +0,0 @@ -# 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/backend/prisma/migrations/20250917210606_add_github_repo_url/migration.sql b/backend/prisma/migrations/20250917210606_add_github_repo_url/migration.sql new file mode 100644 index 0000000..66cd57a --- /dev/null +++ b/backend/prisma/migrations/20250917210606_add_github_repo_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "settings" ADD COLUMN "github_repo_url" TEXT NOT NULL DEFAULT 'git@github.com:9technologygroup/patchmon.net.git'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7503af7..eb0f617 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -179,6 +179,7 @@ model Settings { 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 + githubRepoUrl String @map("github_repo_url") @default("git@github.com:9technologygroup/patchmon.net.git") // GitHub repository URL for version checking createdAt DateTime @map("created_at") @default(now()) updatedAt DateTime @map("updated_at") @updatedAt diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index 32db367..6baea11 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -262,6 +262,67 @@ router.delete('/admin/users/:userId', authenticateToken, requireManageUsers, asy } }); +// Admin endpoint to reset user password +router.post('/admin/users/:userId/reset-password', authenticateToken, requireManageUsers, [ + body('newPassword').isLength({ min: 6 }).withMessage('New password must be at least 6 characters') +], async (req, res) => { + try { + const { userId } = req.params; + const errors = validationResult(req); + + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { newPassword } = req.body; + + // Check if user exists + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + username: true, + email: true, + role: true, + isActive: true + } + }); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + // Prevent resetting password of inactive users + if (!user.isActive) { + return res.status(400).json({ error: 'Cannot reset password for inactive user' }); + } + + // Hash new password + const passwordHash = await bcrypt.hash(newPassword, 12); + + // Update user password + await prisma.user.update({ + where: { id: userId }, + data: { passwordHash } + }); + + // Log the password reset action (you might want to add an audit log table) + console.log(`Password reset for user ${user.username} (${user.email}) by admin ${req.user.username}`); + + res.json({ + message: 'Password reset successfully', + user: { + id: user.id, + username: user.username, + email: user.email + } + }); + } catch (error) { + console.error('Password reset error:', error); + res.status(500).json({ error: 'Failed to reset password' }); + } +}); + // Login router.post('/login', [ body('username').notEmpty().withMessage('Username is required'), diff --git a/backend/src/routes/dashboardRoutes.js b/backend/src/routes/dashboardRoutes.js index c5483fb..3b23566 100644 --- a/backend/src/routes/dashboardRoutes.js +++ b/backend/src/routes/dashboardRoutes.js @@ -15,7 +15,15 @@ const prisma = new PrismaClient(); router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) => { try { const now = new Date(); - const twentyFourHoursAgo = moment(now).subtract(24, 'hours').toDate(); + + // Get the agent update interval setting + const settings = await prisma.settings.findFirst(); + const updateIntervalMinutes = settings?.updateInterval || 60; // Default to 60 minutes if no setting + + // Calculate the threshold based on the actual update interval + // Use 2x the update interval as the threshold for "errored" hosts + const thresholdMinutes = updateIntervalMinutes * 2; + const thresholdTime = moment(now).subtract(thresholdMinutes, 'minutes').toDate(); // Get all statistics in parallel for better performance const [ @@ -49,12 +57,12 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) = where: { needsUpdate: true } }), - // Errored hosts (not updated in 24 hours) + // Errored hosts (not updated within threshold based on update interval) prisma.host.count({ where: { status: 'active', lastUpdate: { - lt: twentyFourHoursAgo + lt: thresholdTime } } }), @@ -180,10 +188,25 @@ router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => { } }); + // Get the agent update interval setting for stale calculation + const settings = await prisma.settings.findFirst(); + const updateIntervalMinutes = settings?.updateInterval || 60; + const thresholdMinutes = updateIntervalMinutes * 2; + + // Calculate effective status based on reporting interval + const isStale = moment(host.lastUpdate).isBefore(moment().subtract(thresholdMinutes, 'minutes')); + let effectiveStatus = host.status; + + // Override status if host hasn't reported within threshold + if (isStale && host.status === 'active') { + effectiveStatus = 'inactive'; + } + return { ...host, updatesCount, - isStale: moment(host.lastUpdate).isBefore(moment().subtract(24, 'hours')) + isStale, + effectiveStatus }; }) ); diff --git a/backend/src/routes/settingsRoutes.js b/backend/src/routes/settingsRoutes.js index 9bba6c5..bb05633 100644 --- a/backend/src/routes/settingsRoutes.js +++ b/backend/src/routes/settingsRoutes.js @@ -122,7 +122,8 @@ router.put('/', authenticateToken, requireManageSettings, [ 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') + body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean'), + body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string') ], async (req, res) => { try { console.log('Settings update request body:', req.body); @@ -132,8 +133,8 @@ router.put('/', authenticateToken, requireManageSettings, [ 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 }); + const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl } = req.body; + console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl }); // Construct server URL from components const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`; @@ -149,7 +150,8 @@ router.put('/', authenticateToken, requireManageSettings, [ serverPort, frontendUrl, updateInterval: updateInterval || 60, - autoUpdate: autoUpdate || false + autoUpdate: autoUpdate || false, + githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git' }); const oldUpdateInterval = settings.updateInterval; @@ -162,7 +164,8 @@ router.put('/', authenticateToken, requireManageSettings, [ serverPort, frontendUrl, updateInterval: updateInterval || 60, - autoUpdate: autoUpdate || false + autoUpdate: autoUpdate || false, + githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git' } }); console.log('Settings updated successfully:', settings); @@ -182,7 +185,8 @@ router.put('/', authenticateToken, requireManageSettings, [ serverPort, frontendUrl, updateInterval: updateInterval || 60, - autoUpdate: autoUpdate || false + autoUpdate: autoUpdate || false, + githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git' } }); } diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 57593f3..86f45c5 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -18,7 +18,8 @@ import { Clock, RefreshCw, GitBranch, - Wrench + Wrench, + Plus } from 'lucide-react' import { useState, useEffect, useRef } from 'react' import { useQuery } from '@tanstack/react-query' @@ -59,7 +60,7 @@ const Layout = ({ children }) => { ] }, { - section: 'Users', + section: 'PatchMon Users', items: [ ...(canViewUsers() ? [{ name: 'Users', href: '/users', icon: Users }] : []), ...(canManageSettings() ? [{ name: 'Permissions', href: '/permissions', icon: Shield }] : []), @@ -68,7 +69,7 @@ const Layout = ({ children }) => { { section: 'Settings', items: [ - ...(canManageSettings() ? [{ name: 'Settings', href: '/settings', icon: Settings }] : []), + ...(canManageSettings() ? [{ name: 'Server Config', href: '/settings', icon: Settings }] : []), ] } ] @@ -100,6 +101,26 @@ const Layout = ({ children }) => { setUserMenuOpen(false) } + const handleAddHost = () => { + // Navigate to hosts page with add modal parameter + window.location.href = '/hosts?action=add' + } + + // Short format for navigation area + const formatRelativeTimeShort = (date) => { + const now = new Date() + const diff = now - new Date(date) + const seconds = Math.floor(diff / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (days > 0) return `${days}d ago` + if (hours > 0) return `${hours}h ago` + if (minutes > 0) return `${minutes}m ago` + return `${seconds}s ago` + } + // Save sidebar collapsed state to localStorage useEffect(() => { localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed)) @@ -168,26 +189,57 @@ const Layout = ({ children }) => {
{item.items.map((subItem) => ( - e.preventDefault() : () => setSidebarOpen(false)} - > - - - {subItem.name} - {subItem.comingSoon && ( - - Soon +
+ {subItem.name === 'Hosts' && canManageHosts() ? ( + // Special handling for Hosts item with integrated + button (mobile) + setSidebarOpen(false)} + > + + + {subItem.name} - )} - - + + + ) : ( + // Standard navigation item (mobile) + e.preventDefault() : () => setSidebarOpen(false)} + > + + + {subItem.name} + {subItem.comingSoon && ( + + Soon + + )} + + + )} +
))}
@@ -268,30 +320,65 @@ const Layout = ({ children }) => { @@ -304,39 +391,41 @@ const Layout = ({ children }) => { {/* Profile Section - Bottom of Sidebar */} -
+
{!sidebarCollapsed ? ( -
- {/* My Profile Link */} - - - My Profile - - - {/* User Info with Sign Out */} +
+ {/* User Info with Sign Out - Username is clickable */}
-
-
-

- {user?.username} -

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

- {user?.email} -

-
+
+ {/* Updated info */} + {stats && ( +
+
+ + Updated: {formatRelativeTimeShort(stats.lastUpdated)} + +
+
+ )}
) : (
- + + {/* Updated info for collapsed sidebar */} + {stats && ( +
+ +
+ )}
)}
@@ -369,7 +490,7 @@ const Layout = ({ children }) => {
{/* Main content */} -
{/* Top bar */} @@ -392,22 +513,6 @@ const Layout = ({ children }) => {
- - {/* Last updated info */} - {stats && ( -
- - Last updated: {formatRelativeTime(stats.lastUpdated)} - -
- )} - {/* Customize Dashboard Button - Only show on Dashboard page */} {location.pathname === '/' && (
-
+
{children}
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index be63256..c7ed2dc 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -12,7 +12,7 @@ import { } 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 { dashboardAPI, dashboardPreferencesAPI, settingsAPI, formatRelativeTime } from '../utils/api' import DashboardSettingsModal from '../components/DashboardSettingsModal' import { useTheme } from '../contexts/ThemeContext' @@ -42,6 +42,48 @@ const Dashboard = () => { navigate('/packages?filter=security') } + const handleErroredHostsClick = () => { + navigate('/hosts?filter=inactive') + } + + const handleOSDistributionClick = () => { + navigate('/hosts') + } + + const handleUpdateStatusClick = () => { + navigate('/hosts') + } + + const handlePackagePriorityClick = () => { + navigate('/packages?filter=security') + } + + // Helper function to format the update interval threshold + const formatUpdateIntervalThreshold = () => { + if (!settings?.updateInterval) return '24 hours' + + const intervalMinutes = settings.updateInterval + const thresholdMinutes = intervalMinutes * 2 // 2x the update interval + + if (thresholdMinutes < 60) { + return `${thresholdMinutes} minutes` + } else if (thresholdMinutes < 1440) { + const hours = Math.floor(thresholdMinutes / 60) + const minutes = thresholdMinutes % 60 + if (minutes === 0) { + return `${hours} hour${hours > 1 ? 's' : ''}` + } + return `${hours}h ${minutes}m` + } else { + const days = Math.floor(thresholdMinutes / 1440) + const hours = Math.floor((thresholdMinutes % 1440) / 60) + if (hours === 0) { + return `${days} day${days > 1 ? 's' : ''}` + } + return `${days}d ${hours}h` + } + } + const { data: stats, isLoading, error, refetch } = useQuery({ queryKey: ['dashboardStats'], queryFn: () => dashboardAPI.getStats().then(res => res.data), @@ -49,6 +91,12 @@ const Dashboard = () => { staleTime: 30000, // Consider data stale after 30 seconds }) + // Fetch settings to get the agent update interval + const { data: settings } = useQuery({ + queryKey: ['settings'], + queryFn: () => settingsAPI.get().then(res => res.data), + }) + // Fetch user's dashboard preferences const { data: preferences, refetch: refetchPreferences } = useQuery({ queryKey: ['dashboardPreferences'], @@ -210,11 +258,14 @@ const Dashboard = () => { case 'erroredHosts': return ( -
0 - ? 'bg-danger-50 border-danger-200' - : 'bg-success-50 border-success-200' - }`}> +
0 + ? 'bg-danger-50 border-danger-200' + : 'bg-success-50 border-success-200' + }`} + onClick={handleErroredHostsClick} + >
0 ? 'text-danger-400' : 'text-success-400' @@ -223,7 +274,7 @@ const Dashboard = () => { {stats.cards.erroredHosts > 0 ? ( <>

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

These hosts may be offline or experiencing connectivity issues. @@ -235,7 +286,7 @@ const Dashboard = () => { All hosts are reporting normally

- No hosts have failed to report in the last 24 hours. + No hosts have failed to report in the last {formatUpdateIntervalThreshold()}.

)} @@ -246,7 +297,10 @@ const Dashboard = () => { case 'osDistribution': return ( -
+

OS Distribution

@@ -256,7 +310,10 @@ const Dashboard = () => { case 'updateStatus': return ( -
+

Update Status

@@ -266,7 +323,10 @@ const Dashboard = () => { case 'packagePriority': return ( -
+

Package Priority

diff --git a/frontend/src/pages/HostDetail.jsx b/frontend/src/pages/HostDetail.jsx index 27dfdaa..3429a4f 100644 --- a/frontend/src/pages/HostDetail.jsx +++ b/frontend/src/pages/HostDetail.jsx @@ -21,7 +21,9 @@ import { Code, EyeOff, ToggleLeft, - ToggleRight + ToggleRight, + Edit, + Check } from 'lucide-react' import { dashboardAPI, adminHostsAPI, settingsAPI, formatRelativeTime, formatDate } from '../utils/api' @@ -31,6 +33,8 @@ const HostDetail = () => { const queryClient = useQueryClient() const [showCredentialsModal, setShowCredentialsModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) + const [isEditingHostname, setIsEditingHostname] = useState(false) + const [editedHostname, setEditedHostname] = useState('') const { data: host, isLoading, error, refetch } = useQuery({ queryKey: ['host', hostId], @@ -39,6 +43,13 @@ const HostDetail = () => { staleTime: 30000, }) + // Auto-show credentials modal for new/pending hosts + React.useEffect(() => { + if (host && host.status === 'pending') { + setShowCredentialsModal(true) + } + }, [host]) + const deleteHostMutation = useMutation({ mutationFn: (hostId) => adminHostsAPI.delete(hostId), onSuccess: () => { diff --git a/frontend/src/pages/Hosts.jsx b/frontend/src/pages/Hosts.jsx index c1690a7..4860a96 100644 --- a/frontend/src/pages/Hosts.jsx +++ b/frontend/src/pages/Hosts.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Link, useSearchParams } from 'react-router-dom' +import { Link, useSearchParams, useNavigate } from 'react-router-dom' import { Server, AlertTriangle, @@ -88,67 +88,105 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => { return (
-
+
-

Add New Host

-
-
+
- + setFormData({ ...formData, hostname: e.target.value })} - className="mt-1 block w-full border-secondary-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500" + className="block w-full px-3 py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200" placeholder="server.example.com" /> -

+

System information (OS, IP, architecture) will be automatically detected when the agent connects.

- - -

+

+

Optional: Assign this host to a group for better organization.

{error && ( -
-

{error}

+
+

{error}

)} -
+
@@ -564,6 +602,7 @@ const Hosts = () => { const [selectedHosts, setSelectedHosts] = useState([]) const [showBulkAssignModal, setShowBulkAssignModal] = useState(false) const [searchParams] = useSearchParams() + const navigate = useNavigate() // Table state const [searchTerm, setSearchTerm] = useState('') @@ -575,15 +614,35 @@ const Hosts = () => { const [showFilters, setShowFilters] = useState(false) const [groupBy, setGroupBy] = useState('none') const [showColumnSettings, setShowColumnSettings] = useState(false) + const [hideStale, setHideStale] = useState(false) // Handle URL filter parameters useEffect(() => { const filter = searchParams.get('filter') if (filter === 'needsUpdates') { setShowFilters(true) + setStatusFilter('all') // We'll filter hosts with updates > 0 in the filtering logic + } else if (filter === 'inactive') { + setShowFilters(true) + setStatusFilter('inactive') + // We'll filter hosts with inactive status in the filtering logic + } else if (filter === 'upToDate') { + setShowFilters(true) + setStatusFilter('active') + // We'll filter hosts that are up to date in the filtering logic } - }, [searchParams]) + + // Handle add host action from navigation + const action = searchParams.get('action') + if (action === 'add') { + setShowAddModal(true) + // Remove the action parameter from URL without triggering a page reload + const newSearchParams = new URLSearchParams(searchParams) + newSearchParams.delete('action') + navigate(`/hosts${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ''}`, { replace: true }) + } + }, [searchParams, navigate]) // Column configuration const [columnConfig, setColumnConfig] = useState(() => { @@ -726,16 +785,22 @@ const Hosts = () => { (groupFilter !== 'ungrouped' && host.hostGroup?.id === groupFilter) // Status filter - const matchesStatus = statusFilter === 'all' || host.status === statusFilter + const matchesStatus = statusFilter === 'all' || (host.effectiveStatus || host.status) === statusFilter // OS filter const matchesOs = osFilter === 'all' || host.osType?.toLowerCase() === osFilter.toLowerCase() - // URL filter for hosts needing updates + // URL filter for hosts needing updates, inactive hosts, or up-to-date hosts const filter = searchParams.get('filter') - const matchesUrlFilter = filter !== 'needsUpdates' || (host.updatesCount && host.updatesCount > 0) + const matchesUrlFilter = + (filter !== 'needsUpdates' || (host.updatesCount && host.updatesCount > 0)) && + (filter !== 'inactive' || (host.effectiveStatus || host.status) === 'inactive') && + (filter !== 'upToDate' || (!host.isStale && host.updatesCount === 0)) - return matchesSearch && matchesGroup && matchesStatus && matchesOs && matchesUrlFilter + // Hide stale filter + const matchesHideStale = !hideStale || !host.isStale + + return matchesSearch && matchesGroup && matchesStatus && matchesOs && matchesUrlFilter && matchesHideStale }) // Sorting @@ -768,8 +833,8 @@ const Hosts = () => { bValue = b.agentVersion?.toLowerCase() || 'zzz_no_version' break case 'status': - aValue = a.status - bValue = b.status + aValue = a.effectiveStatus || a.status + bValue = b.effectiveStatus || b.status break case 'updates': aValue = a.updatesCount || 0 @@ -806,7 +871,7 @@ const Hosts = () => { groupKey = host.hostGroup?.name || 'Ungrouped' break case 'status': - groupKey = host.status.charAt(0).toUpperCase() + host.status.slice(1) + groupKey = (host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1) break case 'os': groupKey = host.osType || 'Unknown' @@ -957,7 +1022,7 @@ const Hosts = () => { case 'status': return (
- {host.status.charAt(0).toUpperCase() + host.status.slice(1)} + {(host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1)}
) case 'updates': @@ -989,7 +1054,50 @@ const Hosts = () => { const handleHostCreated = (newHost) => { queryClient.invalidateQueries(['hosts']) - // Host created successfully - user can view details for setup instructions + // Navigate to host detail page to show credentials and setup instructions + navigate(`/hosts/${newHost.hostId}`) + } + + // Stats card click handlers + const handleTotalHostsClick = () => { + // Clear all filters to show all hosts + setSearchTerm('') + setGroupFilter('all') + setStatusFilter('all') + setOsFilter('all') + setGroupBy('none') + setHideStale(false) + setShowFilters(false) + } + + const handleUpToDateClick = () => { + // Filter to show only up-to-date hosts + setStatusFilter('active') + setShowFilters(true) + // Use the upToDate URL filter + const newSearchParams = new URLSearchParams(window.location.search) + newSearchParams.set('filter', 'upToDate') + navigate(`/hosts?${newSearchParams.toString()}`, { replace: true }) + } + + const handleNeedsUpdatesClick = () => { + // Filter to show hosts needing updates (regardless of status) + setStatusFilter('all') + setShowFilters(true) + // We'll use the existing needsUpdates URL filter logic + const newSearchParams = new URLSearchParams(window.location.search) + newSearchParams.set('filter', 'needsUpdates') + navigate(`/hosts?${newSearchParams.toString()}`, { replace: true }) + } + + const handleStaleClick = () => { + // Filter to show stale/inactive hosts + setStatusFilter('inactive') + setShowFilters(true) + // We'll use the existing inactive URL filter logic + const newSearchParams = new URLSearchParams(window.location.search) + newSearchParams.set('filter', 'inactive') + navigate(`/hosts?${newSearchParams.toString()}`, { replace: true }) } if (isLoading) { @@ -1045,7 +1153,10 @@ const Hosts = () => { {/* Stats Summary */}
-
+
@@ -1054,7 +1165,10 @@ const Hosts = () => {
-
+
@@ -1065,18 +1179,24 @@ const Hosts = () => {
-
+

Needs Updates

- {hosts?.filter(h => !h.isStale && h.updatesCount > 0).length || 0} + {hosts?.filter(h => h.updatesCount > 0).length || 0}

-
+
@@ -1150,15 +1270,22 @@ const Hosts = () => { - +
+
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index ecb2408..bde3af6 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -10,7 +10,8 @@ const Settings = () => { serverPort: 3001, frontendUrl: 'http://localhost:3000', updateInterval: 60, - autoUpdate: false + autoUpdate: false, + githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git' }); const [errors, setErrors] = useState({}); const [isDirty, setIsDirty] = useState(false); @@ -22,7 +23,8 @@ const Settings = () => { const tabs = [ { id: 'server', name: 'Server Configuration', icon: Server }, { id: 'frontend', name: 'Frontend Configuration', icon: Globe }, - { id: 'agent', name: 'Agent Management', icon: SettingsIcon } + { id: 'agent', name: 'Agent Management', icon: SettingsIcon }, + { id: 'version', name: 'Server Version', icon: Code } ]; // Agent version management state @@ -54,7 +56,8 @@ const Settings = () => { serverPort: settings.serverPort || 3001, frontendUrl: settings.frontendUrl || 'http://localhost:3000', updateInterval: settings.updateInterval || 60, - autoUpdate: settings.autoUpdate || false + autoUpdate: settings.autoUpdate || false, + githubRepoUrl: settings.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git' }; console.log('Setting form data to:', newFormData); setFormData(newFormData); @@ -653,6 +656,100 @@ const Settings = () => { )}
)} + + {/* Server Version Tab */} + {activeTab === 'version' && ( +
+
+ +

Server Version Management

+
+ +
+

Version Check Configuration

+

+ Configure automatic version checking against your GitHub repository to notify users of available updates. +

+ +
+
+ + handleInputChange('githubRepoUrl', e.target.value)} + className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm" + placeholder="git@github.com:username/repository.git" + /> +

+ SSH or HTTPS URL to your GitHub repository +

+
+ +
+
+
+ + Current Version +
+ 1.2.3 +
+ +
+
+ + Latest Version +
+ Checking... +
+
+ +
+ + + +
+
+
+ +
+
+ +
+

Setup Instructions

+
+

To enable version checking, you need to:

+
    +
  1. Create a version tag (e.g., v1.2.3) in your GitHub repository
  2. +
  3. Ensure the repository is publicly accessible or configure access tokens
  4. +
  5. Click "Check for Updates" to verify the connection
  6. +
+
+
+
+
+
+ )}
diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx index 5320b78..36a5719 100644 --- a/frontend/src/pages/Users.jsx +++ b/frontend/src/pages/Users.jsx @@ -1,12 +1,13 @@ import React, { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Plus, Trash2, Edit, User, Mail, Shield, Calendar, CheckCircle, XCircle } from 'lucide-react' +import { Plus, Trash2, Edit, User, Mail, Shield, Calendar, CheckCircle, XCircle, Key } from 'lucide-react' import { adminUsersAPI, permissionsAPI } from '../utils/api' import { useAuth } from '../contexts/AuthContext' const Users = () => { const [showAddModal, setShowAddModal] = useState(false) const [editingUser, setEditingUser] = useState(null) + const [resetPasswordUser, setResetPasswordUser] = useState(null) const queryClient = useQueryClient() const { user: currentUser } = useAuth() @@ -39,6 +40,15 @@ const Users = () => { } }) + // Reset password mutation + const resetPasswordMutation = useMutation({ + mutationFn: ({ userId, newPassword }) => adminUsersAPI.resetPassword(userId, newPassword), + onSuccess: () => { + queryClient.invalidateQueries(['users']) + setResetPasswordUser(null) + } + }) + const handleDeleteUser = async (userId, username) => { if (window.confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) { try { @@ -58,6 +68,10 @@ const Users = () => { setEditingUser(user) } + const handleResetPassword = (user) => { + setResetPasswordUser(user) + } + if (isLoading) { return (
@@ -156,6 +170,18 @@ const Users = () => { > +
) } @@ -487,4 +524,126 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => { ) } +// Reset Password Modal Component +const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading }) => { + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [error, setError] = useState('') + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + + // Validate passwords + if (newPassword.length < 6) { + setError('Password must be at least 6 characters long') + return + } + + if (newPassword !== confirmPassword) { + setError('Passwords do not match') + return + } + + try { + await onPasswordReset({ userId: user.id, newPassword }) + // Reset form on success + setNewPassword('') + setConfirmPassword('') + } catch (err) { + setError(err.response?.data?.error || 'Failed to reset password') + } + } + + const handleClose = () => { + setNewPassword('') + setConfirmPassword('') + setError('') + onClose() + } + + if (!isOpen) return null + + return ( +
+
+

+ Reset Password for {user.username} +

+ + +
+ + setNewPassword(e.target.value)} + className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white" + placeholder="Enter new password (min 6 characters)" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white" + placeholder="Confirm new password" + /> +
+ +
+
+
+ +
+
+

+ Password Reset Warning +

+
+

This will immediately change the user's password. The user will need to use the new password to login.

+
+
+
+
+ + {error && ( +
+

{error}

+
+ )} + +
+ + +
+ +
+
+ ) +} + export default Users diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 2cab793..3453068 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -73,7 +73,8 @@ export const adminUsersAPI = { list: () => api.get('/auth/admin/users'), create: (userData) => api.post('/auth/admin/users', userData), update: (userId, userData) => api.put(`/auth/admin/users/${userId}`, userData), - delete: (userId) => api.delete(`/auth/admin/users/${userId}`) + delete: (userId) => api.delete(`/auth/admin/users/${userId}`), + resetPassword: (userId, newPassword) => api.post(`/auth/admin/users/${userId}/reset-password`, { newPassword }) } // Permissions API (for role management) diff --git a/manage-patchmon.sh b/manage-patchmon.sh new file mode 100755 index 0000000..1fb66a8 --- /dev/null +++ b/manage-patchmon.sh @@ -0,0 +1,1591 @@ +#!/bin/bash +# PatchMon Unified Management Script +# Usage: ./manage-patchmon.sh [github-repo] + +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 + +# Global variables +FQDN="" +GITHUB_REPO="" +DB_SAFE_NAME="" +DB_NAME="" +DB_USER="" +DB_PASS="" +JWT_SECRET="" +BACKEND_PORT="" +APP_DIR="" +SERVICE_NAME="" +USE_LETSENCRYPT="false" +SERVER_PROTOCOL_SEL="http" +SERVER_PORT_SEL=80 + +# Functions +print_status() { + echo -e "${GREEN}✅ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +print_error() { + echo -e "${RED}❌ $1${NC}" +} + +print_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +# Check if system component is already installed +check_system_component() { + local component=$1 + case $component in + "postgresql") + systemctl is-active postgresql >/dev/null 2>&1 && return 0 || return 1 + ;; + "nginx") + systemctl is-active nginx >/dev/null 2>&1 && return 0 || return 1 + ;; + "nodejs") + command -v node >/dev/null 2>&1 && return 0 || return 1 + ;; + "certbot") + command -v certbot >/dev/null 2>&1 && return 0 || return 1 + ;; + *) + return 1 + ;; + esac +} + +# Function to find available port +find_available_port() { + local start_port=3001 + local port=$start_port + + while true; do + # Check if port is in use using multiple methods for reliability + if ! netstat -tuln 2>/dev/null | grep -q ":$port " && \ + ! ss -tuln 2>/dev/null | grep -q ":$port " && \ + ! lsof -i :$port 2>/dev/null | grep -q ":$port"; then + echo $port + return 0 + fi + port=$((port + 1)) + + # Safety check to prevent infinite loop + if [ $port -gt 3100 ]; then + print_error "Could not find available port between 3001-3100" + exit 1 + fi + done +} + +# Initialize instance variables +init_instance_vars() { + DB_SAFE_NAME=$(echo $FQDN | tr '[:upper:]' '[:lower:]' | tr '.-' '__') + DB_NAME="patchmon_${DB_SAFE_NAME}" + DB_USER="patchmon_${DB_SAFE_NAME}_user" + DB_PASS=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-25) + JWT_SECRET=$(openssl rand -base64 64 | tr -d "=+/" | cut -c1-50) + # Show currently used ports for debugging + echo -e "${BLUE}🔍 Checking currently used ports...${NC}" + echo "Ports 3000-3010 in use:" + for p in {3000..3010}; do + if netstat -tuln 2>/dev/null | grep -q ":$p " || \ + ss -tuln 2>/dev/null | grep -q ":$p " || \ + lsof -i :$p 2>/dev/null | grep -q ":$p"; then + echo " Port $p: IN USE" + fi + done + + BACKEND_PORT=$(find_available_port) + FRONTEND_PORT=$((BACKEND_PORT + 1)) + APP_DIR="/opt/patchmon-$FQDN" + SERVICE_NAME="patchmon-$FQDN" +} + +# Ask whether to enable Let's Encrypt / HTTPS +choose_ssl_option() { + echo -e "${BLUE}🔒 SSL/HTTPS Configuration${NC}" + echo "This installer can configure Let's Encrypt for HTTPS (public FQDN required)." + echo "If you plan to run PatchMon internally (behind NAT) or without public DNS, choose 'N'." + read -p "Enable Let's Encrypt HTTPS? (Y/n): " ENABLE_SSL + ENABLE_SSL=${ENABLE_SSL:-Y} + if [[ "$ENABLE_SSL" =~ ^[Yy]$ ]]; then + USE_LETSENCRYPT="true" + SERVER_PROTOCOL_SEL="https" + SERVER_PORT_SEL=443 + else + USE_LETSENCRYPT="false" + SERVER_PROTOCOL_SEL="http" + SERVER_PORT_SEL=80 + fi + export USE_LETSENCRYPT SERVER_PROTOCOL_SEL SERVER_PORT_SEL + print_status "SSL option selected: ${USE_LETSENCRYPT} (protocol=${SERVER_PROTOCOL_SEL}, port=${SERVER_PORT_SEL})" +} + +# Configure timezone and time sync +configure_timezone() { + echo -e "${BLUE}🕒 Checking current time and timezone...${NC}" + echo "Current time: $(date)" + echo "Current timezone: $(timedatectl show -p Timezone --value 2>/dev/null || echo 'unknown')" + echo + read -p "Would you like to change the timezone? (y/N): " change_tz + change_tz=${change_tz:-N} + if [[ "$change_tz" =~ ^[Yy]$ ]]; then + echo -e "${BLUE}🌍 Available timezones example: Europe/London, UTC, America/New_York${NC}" + read -p "Enter timezone (e.g., Europe/London): " NEW_TZ + if [ ! -z "$NEW_TZ" ]; then + timedatectl set-timezone "$NEW_TZ" || print_warning "Failed to set timezone" + print_status "Timezone set to: $NEW_TZ" + fi + fi + + # Enable NTP sync + if ! timedatectl show | grep -q "NTPSynchronized=yes"; then + echo -e "${BLUE}🕐 Enabling NTP time synchronization...${NC}" + timedatectl set-ntp true || print_warning "Failed to enable NTP" + print_status "NTP synchronization enabled" + else + print_info "NTP synchronization already enabled" + fi +} + +# Update system (only if not recently updated) +update_system() { + if [ ! -f /var/cache/apt/pkgcache.bin ] || [ $(find /var/cache/apt/pkgcache.bin -mtime +1) ]; then + echo -e "${BLUE}📦 Updating system packages...${NC}" + apt-get update + apt-get upgrade -y + print_status "System updated" + else + print_info "System packages recently updated, skipping" + fi + + # Install essential tools if not present + if ! command -v curl >/dev/null 2>&1 || ! command -v nc >/dev/null 2>&1 || ! command -v git >/dev/null 2>&1; then + echo -e "${BLUE}📦 Installing essential tools...${NC}" + apt-get install -y curl netcat-openbsd git + print_status "Essential tools installed" + fi +} + +# Install Node.js (if not already installed) +install_nodejs() { + # Force PATH refresh to ensure we get the latest Node.js + export PATH="/usr/bin:/usr/local/bin:$PATH" + hash -r # Clear bash command cache + + NODE_VERSION="" + if command -v node >/dev/null 2>&1; then + NODE_VERSION=$(node --version) + NODE_MAJOR=$(echo $NODE_VERSION | cut -d'.' -f1 | sed 's/v//') + NODE_MINOR=$(echo $NODE_VERSION | cut -d'.' -f2) + + echo -e "${BLUE}🔍 Detected Node.js version: $NODE_VERSION${NC}" + + # Check if Node.js version is sufficient (need 20.19+ or 22.12+) + if [ "$NODE_MAJOR" -gt 22 ] || [ "$NODE_MAJOR" -eq 22 ] || ([ "$NODE_MAJOR" -eq 20 ] && [ "$NODE_MINOR" -ge 19 ]); then + print_info "Node.js $NODE_VERSION is compatible (need 20.19+ or 22.12+)" + + # Check if npm is available + if ! command -v npm >/dev/null 2>&1; then + echo -e "${BLUE}📦 Installing npm...${NC}" + apt-get install -y npm + fi + + # Update npm to compatible version + echo -e "${BLUE}🔧 Ensuring npm compatibility...${NC}" + if [ "$NODE_MAJOR" -ge 22 ]; then + npm install -g npm@latest + else + npm install -g npm@10 + fi + + print_status "Node.js and npm ready" + return + else + print_warning "Node.js $NODE_VERSION is too old (need 20.19+ or 22.12+), upgrading..." + fi + else + echo -e "${BLUE}📦 Node.js not found, installing...${NC}" + fi + + echo -e "${BLUE}📦 Installing Node.js 20...${NC}" + + # Remove old Node.js if present + if command -v node >/dev/null 2>&1; then + echo -e "${YELLOW}🗑️ Removing old Node.js installation...${NC}" + apt-get remove -y nodejs npm || true + apt-get autoremove -y || true + # Clear alternatives + update-alternatives --remove-all node 2>/dev/null || true + update-alternatives --remove-all npm 2>/dev/null || true + fi + + # Clean up old NodeSource repo if present + rm -f /etc/apt/sources.list.d/nodesource.list + rm -f /usr/share/keyrings/nodesource.gpg + + # Install Node.js 20 from NodeSource + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + apt-get install -y nodejs + + # Force PATH refresh again + export PATH="/usr/bin:/usr/local/bin:$PATH" + hash -r + + # Verify installation + if ! command -v node >/dev/null 2>&1; then + print_error "Node.js installation failed - command not found" + exit 1 + fi + + if ! command -v npm >/dev/null 2>&1; then + print_error "npm installation failed - command not found" + exit 1 + fi + + # Update npm to compatible version for Node.js 20 + echo -e "${BLUE}🔧 Updating npm to compatible version...${NC}" + npm install -g npm@10 + + NODE_VERSION=$(node --version) + NPM_VERSION=$(npm --version) + + # Verify version is now correct + NODE_MAJOR=$(echo $NODE_VERSION | cut -d'.' -f1 | sed 's/v//') + if [ "$NODE_MAJOR" -lt 20 ]; then + print_error "Node.js upgrade failed - still showing version $NODE_VERSION" + exit 1 + fi + + print_status "Node.js $NODE_VERSION and npm $NPM_VERSION installed and verified" +} + +# Install PostgreSQL (if not already installed) +install_postgresql() { + if check_system_component "postgresql"; then + print_info "PostgreSQL already installed and running" + return + fi + + echo -e "${BLUE}🗄️ Installing PostgreSQL...${NC}" + apt-get install -y postgresql postgresql-contrib + systemctl enable postgresql + systemctl start postgresql + print_status "PostgreSQL installed and started" +} + +# Install Nginx (if not already installed) +install_nginx() { + if check_system_component "nginx"; then + print_info "Nginx already installed and running" + return + fi + + echo -e "${BLUE}🌐 Installing Nginx...${NC}" + apt-get install -y nginx + systemctl enable nginx + systemctl start nginx + + # Configure firewall if ufw is available + if command -v ufw >/dev/null 2>&1; then + ufw allow 'Nginx Full' || true + fi + + print_status "Nginx installed and started" +} + +# Install Certbot (if SSL is enabled and not already installed) +install_certbot() { + if [ "$USE_LETSENCRYPT" != "true" ]; then + return + fi + + if check_system_component "certbot"; then + print_info "Certbot already installed" + return + fi + + echo -e "${BLUE}🔒 Installing Certbot...${NC}" + apt-get install -y certbot python3-certbot-nginx + print_status "Certbot installed" +} + +# Setup database for instance +setup_database() { + echo -e "${BLUE}📋 Creating database: $DB_NAME${NC}" + + # Drop and recreate database and user for clean state + sudo -u postgres psql -c "DROP DATABASE IF EXISTS $DB_NAME;" || true + sudo -u postgres psql -c "DROP USER IF EXISTS $DB_USER;" || true + sudo -u postgres psql -c "CREATE DATABASE $DB_NAME;" + sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';" + + # Grant comprehensive permissions + sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;" + sudo -u postgres psql -c "ALTER USER $DB_USER CREATEDB;" + + # Set schema permissions + sudo -u postgres psql -d $DB_NAME -c "GRANT USAGE ON SCHEMA public TO $DB_USER;" + sudo -u postgres psql -d $DB_NAME -c "GRANT CREATE ON SCHEMA public TO $DB_USER;" + sudo -u postgres psql -d $DB_NAME -c "ALTER SCHEMA public OWNER TO $DB_USER;" + sudo -u postgres psql -d $DB_NAME -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $DB_USER;" + sudo -u postgres psql -d $DB_NAME -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $DB_USER;" + + # Test database connection + echo -e "${BLUE}🔍 Testing database connection...${NC}" + if PGPASSWORD="$DB_PASS" psql -h localhost -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" >/dev/null 2>&1; then + print_status "Database connection successful" + else + print_error "Database connection failed" + echo "Debug information:" + sudo -u postgres psql -c "\l" | grep "$DB_NAME" || echo "Database not found" + sudo -u postgres psql -c "\du" | grep "$DB_USER" || echo "User not found" + exit 1 + fi + + print_status "Database $DB_NAME and user $DB_USER created and tested" +} + +# Clone application code +clone_application() { + echo -e "${BLUE}📥 Cloning PatchMon application...${NC}" + + # Remove existing directory if it exists + rm -rf $APP_DIR + + # Try SSH first, fallback to HTTPS + SSH_REPO=$(echo $GITHUB_REPO | sed 's|https://github.com/|git@github.com:|') + if ssh -T git@github.com 2>&1 | grep -q "successfully authenticated"; then + print_info "GitHub SSH key detected, using SSH clone" + if git clone $SSH_REPO $APP_DIR 2>/dev/null; then + print_status "Repository cloned via SSH" + else + print_warning "SSH clone failed, trying HTTPS..." + git clone $GITHUB_REPO $APP_DIR + fi + else + git clone $GITHUB_REPO $APP_DIR + fi + + cd $APP_DIR + + # Set initial ownership and create required directories + echo -e "${BLUE}🔐 Setting initial ownership and creating directories...${NC}" + chown -R www-data:www-data $APP_DIR + + # Create logs directory immediately to prevent permission errors + mkdir -p $APP_DIR/backend/logs + chown -R www-data:www-data $APP_DIR/backend/logs + chmod 755 $APP_DIR/backend/logs + + print_status "Repository cloned to $APP_DIR with correct ownership" +} + +# Setup Node.js environment for instance +setup_node_environment() { + echo -e "${BLUE}📦 Setting up Node.js environment for instance...${NC}" + + # Force PATH refresh to ensure we get the latest Node.js + export PATH="/usr/bin:/usr/local/bin:$PATH" + hash -r + + # Verify Node.js and npm are available + if ! command -v node >/dev/null 2>&1; then + print_error "Node.js not found after installation. PATH issue detected." + echo "Current PATH: $PATH" + echo "Available node binaries:" + find /usr -name "node" 2>/dev/null || echo "No node binaries found" + exit 1 + fi + + if ! command -v npm >/dev/null 2>&1; then + print_error "npm not found after installation. PATH issue detected." + echo "Current PATH: $PATH" + echo "Available npm binaries:" + find /usr -name "npm" 2>/dev/null || echo "No npm binaries found" + exit 1 + fi + + NODE_VERSION=$(node --version) + NPM_VERSION=$(npm --version) + NODE_MAJOR=$(echo $NODE_VERSION | cut -d'.' -f1 | sed 's/v//') + NODE_MINOR=$(echo $NODE_VERSION | cut -d'.' -f2) + + # Verify Node.js version is compatible + if [ "$NODE_MAJOR" -lt 20 ] || ([ "$NODE_MAJOR" -eq 20 ] && [ "$NODE_MINOR" -lt 19 ]); then + print_error "Node.js version $NODE_VERSION is incompatible with Vite 7.1.5 (need 20.19+ or 22.12+)" + echo -e "${YELLOW}This suggests the Node.js upgrade failed. Please check the installation.${NC}" + exit 1 + fi + + print_status "Node.js environment ready: Node $NODE_VERSION, npm $NPM_VERSION (compatible)" +} + +# Install application dependencies +install_dependencies() { + echo -e "${BLUE}📦 Installing application dependencies...${NC}" + + # Root dependencies + if [ -f "$APP_DIR/package.json" ]; then + cd $APP_DIR + npm install + fi + + # Backend dependencies + if [ -f "$APP_DIR/backend/package.json" ]; then + cd $APP_DIR/backend + npm install + fi + + # Frontend dependencies + if [ -f "$APP_DIR/frontend/package.json" ]; then + cd $APP_DIR/frontend + npm install + fi + + print_status "Dependencies installed" +} + +# Create environment files +create_env_files() { + echo -e "${BLUE}⚙️ Creating environment files...${NC}" + + # Backend .env + cat > $APP_DIR/backend/.env << EOF +NODE_ENV=production +PORT=$BACKEND_PORT +API_VERSION=v1 + +# Database +DATABASE_URL=postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME?schema=public + +# Security +CORS_ORIGINS=${SERVER_PROTOCOL_SEL}://$FQDN +TRUST_PROXY=1 +ENABLE_HSTS=$([ "$USE_LETSENCRYPT" = "true" ] && echo "true" || echo "false") + +# Rate Limiting +RATE_LIMIT_WINDOW_MS=900000 +RATE_LIMIT_MAX=100 +AUTH_RATE_LIMIT_MAX=5 +HOST_RATE_LIMIT_MAX=50 + +# Body Parsing +JSON_BODY_LIMIT=5mb + +# Logging +ENABLE_LOGGING=true + +# JWT Secret +JWT_SECRET=$JWT_SECRET +EOF + + # Frontend .env (NODE_ENV not needed - Vite handles this automatically) + cat > $APP_DIR/frontend/.env << EOF +VITE_API_URL=${SERVER_PROTOCOL_SEL}://$FQDN/api/v1 +VITE_FRONTEND_URL=${SERVER_PROTOCOL_SEL}://$FQDN +VITE_FRONTEND_PORT=$FRONTEND_PORT +VITE_BACKEND_PORT=$BACKEND_PORT +EOF + + print_status "Environment files created" +} + +# Run database migrations +run_migrations() { + echo -e "${BLUE}🗃️ Running database migrations...${NC}" + cd $APP_DIR/backend + + # Test connection before migrations + if ! PGPASSWORD="$DB_PASS" psql -h localhost -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" >/dev/null 2>&1; then + print_error "Cannot connect to database before migrations" + echo "Debug information:" + sudo -u postgres psql -c "\l" | grep "$DB_NAME" || echo "Database not found" + sudo -u postgres psql -c "\du" | grep "$DB_USER" || echo "User not found" + exit 1 + fi + + # Generate Prisma client + npx prisma generate + + # Run migrations + npx prisma migrate deploy + + print_status "Database migrations completed" +} + +# Seed default roles +seed_default_roles() { + echo -e "${BLUE}🛡️ Seeding default roles...${NC}" + cd $APP_DIR/backend + + # Test connection before seeding + if ! PGPASSWORD="$DB_PASS" psql -h localhost -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" >/dev/null 2>&1; then + print_error "Cannot connect to database before seeding roles" + exit 1 + fi + + node -e " +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function seedRoles() { + try { + // Create admin role with full permissions + await prisma.rolePermissions.upsert({ + where: { role: 'admin' }, + update: { + canViewDashboard: true, + canViewHosts: true, + canManageHosts: true, + canViewPackages: true, + canManagePackages: true, + canViewUsers: true, + canManageUsers: true, + canViewReports: true, + canExportData: true, + canManageSettings: true + }, + create: { + role: 'admin', + canViewDashboard: true, + canViewHosts: true, + canManageHosts: true, + canViewPackages: true, + canManagePackages: true, + canViewUsers: true, + canManageUsers: true, + canViewReports: true, + canExportData: true, + canManageSettings: true + } + }); + + // Create user role with read-only permissions + await prisma.rolePermissions.upsert({ + where: { role: 'user' }, + update: { + canViewDashboard: true, + canViewHosts: true, + canManageHosts: false, + canViewPackages: true, + canManagePackages: false, + canViewUsers: false, + canManageUsers: false, + canViewReports: true, + canExportData: false, + canManageSettings: false + }, + create: { + role: 'user', + canViewDashboard: true, + canViewHosts: true, + canManageHosts: false, + canViewPackages: true, + canManagePackages: false, + canViewUsers: false, + canManageUsers: false, + canViewReports: true, + canExportData: false, + canManageSettings: false + } + }); + + console.log('✅ Default roles seeded successfully'); + } catch (error) { + console.error('❌ Error seeding roles:', error.message); + process.exit(1); + } finally { + await prisma.\$disconnect(); + } +} + +seedRoles(); +" + + print_status "Default roles seeded" +} + +# Build frontend +build_frontend() { + echo -e "${BLUE}🏗️ Building frontend...${NC}" + cd $APP_DIR/frontend + npm run build + print_status "Frontend built successfully" +} + +# Fix permissions for the application +fix_permissions() { + echo -e "${BLUE}🔐 Setting final permissions...${NC}" + + # Ensure entire directory is owned by www-data + chown -R www-data:www-data $APP_DIR + + # Set directory permissions (755 = rwxr-xr-x) + find $APP_DIR -type d -exec chmod 755 {} \; + + # Set file permissions (644 = rw-r--r--) + find $APP_DIR -type f -exec chmod 644 {} \; + + # Make scripts executable + if [ -f "$APP_DIR/manage.sh" ]; then + chmod +x $APP_DIR/manage.sh + fi + + # Ensure logs directory exists with correct permissions + mkdir -p $APP_DIR/backend/logs + chown -R www-data:www-data $APP_DIR/backend/logs + chmod 755 $APP_DIR/backend/logs + + # Make sure node_modules have correct permissions for npm operations + if [ -d "$APP_DIR/node_modules" ]; then + chown -R www-data:www-data $APP_DIR/node_modules + fi + if [ -d "$APP_DIR/backend/node_modules" ]; then + chown -R www-data:www-data $APP_DIR/backend/node_modules + fi + if [ -d "$APP_DIR/frontend/node_modules" ]; then + chown -R www-data:www-data $APP_DIR/frontend/node_modules + fi + + print_status "Final permissions set correctly - entire directory owned by www-data" +} + +# Setup Nginx configuration +setup_nginx() { + echo -e "${BLUE}🌐 Setting up Nginx configuration...${NC}" + + if [ "$USE_LETSENCRYPT" = "true" ]; then + # HTTP-only config first for Certbot challenge + cat > /etc/nginx/sites-available/$FQDN << EOF +server { + listen 80; + server_name $FQDN; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://\$server_name\$request_uri; + } +} +EOF + else + # HTTP-only configuration + cat > /etc/nginx/sites-available/$FQDN << EOF +server { + listen 80; + server_name $FQDN; + + # Frontend + location / { + root $APP_DIR/frontend/dist; + try_files \$uri \$uri/ /index.html; + + # Security headers + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + } + + # API routes + location /api/ { + proxy_pass http://localhost:$BACKEND_PORT; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + 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; + proxy_cache_bypass \$http_upgrade; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } + + # Health check + location /health { + proxy_pass http://localhost:$BACKEND_PORT/health; + access_log off; + } +} +EOF + fi + + # Enable site + ln -sf /etc/nginx/sites-available/$FQDN /etc/nginx/sites-enabled/$FQDN + + # Test configuration + nginx -t + nginx -s reload + + print_status "Nginx configuration created" +} + +# Setup SSL with Let's Encrypt +setup_ssl() { + if [ "$USE_LETSENCRYPT" != "true" ]; then + return + fi + + echo -e "${BLUE}🔒 Setting up SSL certificate...${NC}" + certbot --nginx -d $FQDN --non-interactive --agree-tos --email admin@$FQDN --redirect + + # Update Nginx config with full HTTPS configuration + cat > /etc/nginx/sites-available/$FQDN << EOF +server { + listen 80; + server_name $FQDN; + return 301 https://\$server_name\$request_uri; +} + +server { + listen 443 ssl http2; + server_name $FQDN; + + ssl_certificate /etc/letsencrypt/live/$FQDN/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/$FQDN/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # Frontend + location / { + root $APP_DIR/frontend/dist; + try_files \$uri \$uri/ /index.html; + + # Security headers + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + } + + # API routes + location /api/ { + proxy_pass http://localhost:$BACKEND_PORT; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection 'upgrade'; + 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; + proxy_cache_bypass \$http_upgrade; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } + + # Health check + location /health { + proxy_pass http://localhost:$BACKEND_PORT/health; + access_log off; + } +} +EOF + + nginx -t + nginx -s reload + + print_status "SSL certificate installed and Nginx updated" +} + +# Setup systemd service +setup_service() { + echo -e "${BLUE}🔧 Setting up systemd service...${NC}" + + # Create service file + cat > /etc/systemd/system/$SERVICE_NAME.service << EOF +[Unit] +Description=PatchMon Backend for $FQDN +After=network.target postgresql.service +Requires=postgresql.service + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=$APP_DIR/backend +ExecStart=/usr/bin/node src/server.js +Restart=always +RestartSec=10 +Environment=NODE_ENV=production +Environment=PORT=$BACKEND_PORT +Environment=DATABASE_URL=postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME?schema=public + +# Security +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=$APP_DIR +ProtectHome=true +ProtectKernelTunables=true +ProtectControlGroups=true +SyslogIdentifier=$SERVICE_NAME + +[Install] +WantedBy=multi-user.target +EOF + + # Reload systemd and enable service + systemctl daemon-reload + systemctl enable $SERVICE_NAME + systemctl start $SERVICE_NAME + + # Wait a moment for service to start + sleep 3 + + if systemctl is-active --quiet $SERVICE_NAME; then + print_status "Service $SERVICE_NAME started successfully" + else + print_error "Service $SERVICE_NAME failed to start" + systemctl status $SERVICE_NAME + exit 1 + fi +} + +# Update database settings +update_database_settings() { + echo -e "${BLUE}⚙️ Updating database settings...${NC}" + cd $APP_DIR/backend + + # Test connection before updating settings + if ! PGPASSWORD="$DB_PASS" psql -h localhost -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" >/dev/null 2>&1; then + print_error "Cannot connect to database before updating settings" + exit 1 + fi + + node -e " +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function updateSettings() { + try { + // Check if settings record exists, create or update + const existingSettings = await prisma.settings.findFirst(); + + const settingsData = { + serverUrl: '${SERVER_PROTOCOL_SEL}://$FQDN', + serverProtocol: '${SERVER_PROTOCOL_SEL}', + serverHost: '$FQDN', + serverPort: $SERVER_PORT_SEL, + frontendUrl: '${SERVER_PROTOCOL_SEL}://$FQDN', + updateInterval: 60, + autoUpdate: true + }; + + if (existingSettings) { + // Update existing settings + await prisma.settings.update({ + where: { id: existingSettings.id }, + data: settingsData + }); + } else { + // Create new settings record + await prisma.settings.create({ + data: settingsData + }); + } + + console.log('✅ Database settings updated successfully'); + } catch (error) { + console.error('❌ Error updating settings:', error.message); + process.exit(1); + } finally { + await prisma.\$disconnect(); + } +} + +updateSettings(); +" + + print_status "Database settings updated" +} + +# Create agent version +create_agent_version() { + echo -e "${BLUE}🤖 Creating agent version...${NC}" + cd $APP_DIR/backend + + # Test connection before creating agent version + if ! PGPASSWORD="$DB_PASS" psql -h localhost -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" >/dev/null 2>&1; then + print_error "Cannot connect to database before creating agent version" + exit 1 + fi + + # Copy agent script to backend directory + if [ -f "$APP_DIR/agents/patchmon-agent.sh" ]; then + cp "$APP_DIR/agents/patchmon-agent.sh" "$APP_DIR/backend/" + + node -e " +const fs = require('fs'); +const { PrismaClient } = require('@prisma/client'); +const prisma = new PrismaClient(); + +async function createAgentVersion() { + try { + const agentScript = fs.readFileSync('./patchmon-agent.sh', 'utf8'); + + // Set all existing versions to not be current or default + await prisma.agentVersion.updateMany({ + data: { isCurrent: false, isDefault: false } + }); + + // Create or update version 1.2.3 + await prisma.agentVersion.upsert({ + where: { version: '1.2.3' }, + update: { + scriptContent: agentScript, + isCurrent: true, + isDefault: true, + releaseNotes: 'Version 1.2.3 - Production Ready\\n\\nFeatures:\\n- FQDN Configuration Support\\n- Dynamic server URL configuration\\n- Better production deployment support\\n- Smart crontab update detection\\n- Fixed circular name reference errors in DEB822 repository parsing\\n- Eliminated all warning messages during repository collection\\n\\nTechnical Improvements:\\n- Agent now respects PATCHMON_SERVER environment variable\\n- Fallback to localhost:3001 for development\\n- Better integration with deployment scripts\\n- Enhanced error handling and logging\\n\\nThis version supports dynamic server URL configuration for production deployments.' + }, + create: { + version: '1.2.3', + scriptContent: agentScript, + isCurrent: true, + isDefault: true, + releaseNotes: 'Version 1.2.3 - Production Ready\\n\\nFeatures:\\n- FQDN Configuration Support\\n- Dynamic server URL configuration\\n- Better production deployment support\\n- Smart crontab update detection\\n- Fixed circular name reference errors in DEB822 repository parsing\\n- Eliminated all warning messages during repository collection\\n\\nTechnical Improvements:\\n- Agent now respects PATCHMON_SERVER environment variable\\n- Fallback to localhost:3001 for development\\n- Better integration with deployment scripts\\n- Enhanced error handling and logging\\n\\nThis version supports dynamic server URL configuration for production deployments.' + } + }); + + console.log('✅ Agent version 1.2.3 created/updated successfully'); + } catch (error) { + console.error('❌ Error creating agent version:', error.message); + process.exit(1); + } finally { + await prisma.\$disconnect(); + } +} + +createAgentVersion(); +" + + # Clean up + rm -f "$APP_DIR/backend/patchmon-agent.sh" + + print_status "Agent version created" + else + print_warning "Agent script not found, skipping agent version creation" + fi +} + +# Setup admin user interactively +setup_admin_user() { + echo -e "${BLUE}👤 Setting up admin user...${NC}" + cd $APP_DIR/backend + + # Wait for service to be ready and test connection + echo -e "${BLUE}⏳ Waiting for backend service to be ready...${NC}" + + # First, verify the service is actually running + if ! systemctl is-active $SERVICE_NAME >/dev/null 2>&1; then + print_error "Service $SERVICE_NAME is not running" + systemctl status $SERVICE_NAME + exit 1 + fi + + # Wait for the health endpoint to respond + local max_attempts=30 + local attempt=1 + local health_url="http://localhost:$BACKEND_PORT/health" + + while [ $attempt -le $max_attempts ]; do + # Try multiple methods to check if service is ready + if curl -s --connect-timeout 5 --max-time 10 "$health_url" >/dev/null 2>&1; then + echo -e "${GREEN}✅ Backend service is ready${NC}" + break + elif nc -z localhost $BACKEND_PORT 2>/dev/null; then + # Port is open, but health endpoint might not be ready yet + echo -e "${YELLOW}⏳ Port $BACKEND_PORT is open, waiting for health endpoint... (attempt $attempt/$max_attempts)${NC}" + else + echo -e "${YELLOW}⏳ Waiting for backend service on port $BACKEND_PORT... (attempt $attempt/$max_attempts)${NC}" + fi + + sleep 3 + attempt=$((attempt + 1)) + + if [ $attempt -gt $max_attempts ]; then + print_error "Backend service failed to become ready after $max_attempts attempts" + echo -e "${BLUE}🔍 Debugging information:${NC}" + echo "Service status:" + systemctl status $SERVICE_NAME --no-pager + echo "" + echo "Port check:" + netstat -tuln | grep ":$BACKEND_PORT" || echo "Port $BACKEND_PORT not listening" + echo "" + echo "Recent logs:" + journalctl -u $SERVICE_NAME -n 20 --no-pager + exit 1 + fi + done + + # Test database connection + if ! PGPASSWORD="$DB_PASS" psql -h localhost -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" >/dev/null 2>&1; then + print_error "Cannot connect to database before admin setup" + exit 1 + fi + + # Copy and run admin setup script + cp $APP_DIR/setup-admin-user.js $APP_DIR/backend/ + + echo -e "${BLUE}🔧 Creating admin user interactively...${NC}" + echo -e "${YELLOW}Please follow the prompts to create your admin user:${NC}" + + # Run admin setup with FQDN environment variable + FQDN="$FQDN" node $APP_DIR/backend/setup-admin-user.js + + # Clean up + rm -f $APP_DIR/backend/setup-admin-user.js + + print_status "Admin user setup completed" +} + +# Setup log rotation +setup_log_rotation() { + echo -e "${BLUE}📋 Setting up log rotation...${NC}" + + cat > /etc/logrotate.d/$SERVICE_NAME << EOF +/var/log/$SERVICE_NAME.log { + daily + missingok + rotate 52 + compress + delaycompress + notifempty + create 0644 www-data www-data + postrotate + systemctl reload $SERVICE_NAME + endscript +} +EOF + + print_status "Log rotation configured" +} + +# Save credentials +save_credentials() { + echo -e "${BLUE}💾 Saving instance credentials...${NC}" + + cat > $APP_DIR/credentials.txt << EOF +# PatchMon Instance Credentials for $FQDN +# Generated on: $(date) +# +# IMPORTANT: Keep this file secure and delete it after noting the credentials + +## Database Credentials +Database Name: $DB_NAME +Database User: $DB_USER +Database Password: $DB_PASS + +## JWT Secret +JWT Secret: $JWT_SECRET + +## Application URLs +Frontend URL: https://$FQDN +Backend API: https://$FQDN/api/v1 +Backend Port: $BACKEND_PORT +Frontend Port: $FRONTEND_PORT + +## Default Admin Login +Username: admin +Password: admin123 +(Please change this password after first login) + +## Service Management +Service Name: $SERVICE_NAME +App Directory: $APP_DIR +Management Script: $APP_DIR/manage.sh + +## Database Connection String +DATABASE_URL=postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME?schema=public +EOF + + chmod 600 $APP_DIR/credentials.txt + chown www-data:www-data $APP_DIR/credentials.txt + + print_status "Credentials saved to $APP_DIR/credentials.txt" +} + +# Create management script for instance +create_management_script() { + echo -e "${BLUE}📝 Creating instance management script...${NC}" + + cat > $APP_DIR/manage.sh << EOF +#!/bin/bash +# Management script for $FQDN + +case \$1 in + "status") + systemctl status $SERVICE_NAME + ;; + "start") + systemctl start $SERVICE_NAME + ;; + "stop") + systemctl stop $SERVICE_NAME + ;; + "restart") + systemctl restart $SERVICE_NAME + ;; + "logs") + journalctl -u $SERVICE_NAME -f + ;; + "update") + cd $APP_DIR + git pull + npm install + cd backend && npm install + cd ../frontend && npm install && npm run build + systemctl restart $SERVICE_NAME + ;; + "backup") + pg_dump -h localhost -U $DB_USER $DB_NAME > backup_\$(date +%Y%m%d_%H%M%S).sql + echo "Database backup created" + ;; + "credentials") + echo "Credentials file: $APP_DIR/credentials.txt" + if [ -f "$APP_DIR/credentials.txt" ]; then + echo "Credentials file exists. Use 'cat $APP_DIR/credentials.txt' to view" + else + echo "Credentials file not found" + fi + ;; + "reset-admin") + echo "Resetting admin password to admin123..." + cd $APP_DIR/backend + node -e " +const { PrismaClient } = require('@prisma/client'); +const bcrypt = require('bcryptjs'); +const prisma = new PrismaClient(); + +async function resetAdminPassword() { + try { + const adminUser = await prisma.user.findFirst({ + where: { role: 'admin' } + }); + + if (adminUser) { + const hashedPassword = await bcrypt.hash('admin123', 10); + await prisma.user.update({ + where: { id: adminUser.id }, + data: { password: hashedPassword } + }); + console.log('✅ Admin password reset to admin123'); + } else { + console.log('❌ No admin user found'); + } + } catch (error) { + console.error('❌ Error:', error.message); + } finally { + await prisma.\$disconnect(); + } +} + +resetAdminPassword(); +" + ;; + *) + echo "Usage: \$0 {status|start|stop|restart|logs|update|backup|credentials|reset-admin}" + echo "" + echo "Commands:" + echo " status - Show service status" + echo " start - Start the service" + echo " stop - Stop the service" + echo " restart - Restart the service" + echo " logs - Show live logs" + echo " update - Update application code and restart" + echo " backup - Create database backup" + echo " credentials - Show credentials file location" + echo " reset-admin - Reset admin password to admin123" + ;; +esac +EOF + + chmod +x $APP_DIR/manage.sh + + print_status "Management script created at $APP_DIR/manage.sh" +} + +# Deploy new instance +deploy_instance() { + local fqdn=$1 + local github_repo=$2 + + if [ $# -ne 2 ]; then + print_error "Usage: $0 deploy " + print_info "Example: $0 deploy customer1.patchmon.com https://github.com/yourorg/patchmon" + exit 1 + fi + + # Check if instance already exists + if [ -d "/opt/patchmon-$fqdn" ]; then + print_error "Instance for $fqdn already exists at /opt/patchmon-$fqdn" + print_info "Use 'update' command to update existing instance" + exit 1 + fi + + FQDN=$fqdn + GITHUB_REPO=$github_repo + + print_info "🚀 Deploying PatchMon instance for $FQDN" + + # Initialize variables + init_instance_vars + + # Display generated credentials + echo -e "${BLUE}🔐 Auto-generated credentials:${NC}" + echo -e "${YELLOW}Database Name: $DB_NAME${NC}" + echo -e "${YELLOW}Database User: $DB_USER${NC}" + echo -e "${YELLOW}Database Password: $DB_PASS${NC}" + echo -e "${YELLOW}JWT Secret: $JWT_SECRET${NC}" + echo -e "${YELLOW}Backend Port: $BACKEND_PORT${NC}" + echo -e "${YELLOW}Frontend Port: $FRONTEND_PORT${NC}" + echo -e "${BLUE}📁 App directory: $APP_DIR${NC}" + echo -e "${BLUE}🗄️ Database: $DB_NAME${NC}" + echo -e "${BLUE}👤 Database user: $DB_USER${NC}" + echo "" + + # Interactive configuration + choose_ssl_option + + # Only configure timezone on first deployment + if ! check_system_component "postgresql"; then + configure_timezone + fi + + # System setup (smart detection) + update_system + install_nodejs + install_postgresql + install_nginx + install_certbot + + # Instance-specific setup + setup_database + clone_application + setup_node_environment + install_dependencies + create_env_files + run_migrations + seed_default_roles + build_frontend + setup_nginx + setup_ssl + setup_service + update_database_settings + create_agent_version + setup_admin_user + setup_log_rotation + save_credentials + create_management_script + fix_permissions + + # Final status + echo -e "${GREEN}🎉 PatchMon deployment completed successfully!${NC}" + echo -e "${GREEN}🌐 Frontend URL: ${SERVER_PROTOCOL_SEL}://$FQDN${NC}" + echo -e "${GREEN}🔗 API URL: ${SERVER_PROTOCOL_SEL}://$FQDN/api/v1${NC}" + echo -e "${GREEN}⚡ Backend Port: $BACKEND_PORT${NC}" + echo -e "${GREEN}📁 App directory: $APP_DIR${NC}" + echo -e "${GREEN}🔧 Management: $APP_DIR/manage.sh${NC}" + echo -e "${GREEN}📊 Service: systemctl status $SERVICE_NAME${NC}" + echo -e "${YELLOW}🔐 Credentials saved to: $APP_DIR/credentials.txt${NC}" + echo -e "${YELLOW}⚠️ Please note down the credentials and delete the file for security${NC}" + echo -e "${BLUE}📋 Next steps:${NC}" + echo -e "${BLUE} 1. Visit ${SERVER_PROTOCOL_SEL}://$FQDN and login with your admin credentials${NC}" + echo -e "${BLUE} 2. Use '$APP_DIR/manage.sh' for service management${NC}" + echo -e "${BLUE} 3. Check '$APP_DIR/manage.sh credentials' for database details${NC}" + echo -e "${BLUE} 4. Install agents using: curl -s ${SERVER_PROTOCOL_SEL}://$FQDN/api/v1/hosts/agent/download | bash${NC}" +} + +# Update existing instance +update_instance() { + local fqdn=$1 + + if [ $# -ne 1 ]; then + print_error "Usage: $0 update " + exit 1 + fi + + local app_dir="/opt/patchmon-$fqdn" + local service_name="patchmon-$fqdn" + + if [ ! -d "$app_dir" ]; then + print_error "Instance for $fqdn not found at $app_dir" + exit 1 + fi + + print_info "Updating PatchMon instance for $fqdn..." + + cd "$app_dir" + + # Backup database first + print_info "Creating database backup..." + local db_safe_name=$(echo $fqdn | tr '[:upper:]' '[:lower:]' | tr '.-' '__') + local db_name="patchmon_${db_safe_name}" + local db_user="patchmon_${db_safe_name}_user" + + pg_dump -h localhost -U "$db_user" "$db_name" > backup_$(date +%Y%m%d_%H%M%S).sql + + # Update code + print_info "Pulling latest code..." + git pull + + # Update dependencies + print_info "Updating dependencies..." + npm install + cd backend && npm install + cd ../frontend && npm install + + # Run migrations + print_info "Running database migrations..." + cd ../backend + npx prisma migrate deploy + + # Rebuild frontend + print_info "Rebuilding frontend..." + cd ../frontend + npm run build + + # Restart service + print_info "Restarting service..." + systemctl restart "$service_name" + + print_status "Instance updated successfully" +} + +# Delete instance +delete_instance() { + local fqdn=$1 + + if [ $# -ne 1 ]; then + print_error "Usage: $0 delete " + exit 1 + fi + + local db_safe_name=$(echo $fqdn | tr '[:upper:]' '[:lower:]' | tr '.-' '__') + local db_name="patchmon_${db_safe_name}" + local db_user="patchmon_${db_safe_name}_user" + local app_dir="/opt/patchmon-$fqdn" + local service_name="patchmon-$fqdn" + + if [ ! -d "$app_dir" ]; then + print_error "Instance for $fqdn not found at $app_dir" + exit 1 + fi + + print_warning "This will permanently delete the PatchMon instance for $fqdn" + read -p "Are you sure? (yes/no): " confirm + + if [ "$confirm" != "yes" ]; then + print_info "Deletion cancelled" + exit 0 + fi + + print_info "Deleting PatchMon instance for $fqdn..." + + # Stop and disable service + systemctl stop "$service_name" || true + systemctl disable "$service_name" || true + + # Remove service file + rm -f "/etc/systemd/system/$service_name.service" + systemctl daemon-reload + + # Remove application directory + rm -rf "$app_dir" + + # Remove database + sudo -u postgres psql -c "DROP DATABASE IF EXISTS $db_name;" || true + sudo -u postgres psql -c "DROP USER IF EXISTS $db_user;" || true + + # Remove Nginx configuration + rm -f "/etc/nginx/sites-enabled/$fqdn" + rm -f "/etc/nginx/sites-available/$fqdn" + nginx -s reload + + # Remove SSL certificate + certbot delete --cert-name "$fqdn" --non-interactive || true + + print_status "Instance deleted successfully" +} + +# List all instances +list_instances() { + print_info "Listing all PatchMon instances..." + echo "" + + # Header + printf "${BLUE}%-30s %-10s %-15s %-10s %-15s${NC}\n" "FQDN" "Status" "Backend Port" "SSL" "Service Name" + printf "${BLUE}%-30s %-10s %-15s %-10s %-15s${NC}\n" "$(printf '%*s' 30 | tr ' ' '-')" "$(printf '%*s' 10 | tr ' ' '-')" "$(printf '%*s' 15 | tr ' ' '-')" "$(printf '%*s' 10 | tr ' ' '-')" "$(printf '%*s' 15 | tr ' ' '-')" + + # Collect instance data + declare -A instances + + # Parse systemd services + for service in /etc/systemd/system/patchmon-*.service; do + if [ -f "$service" ]; then + service_name=$(basename "$service" .service) + fqdn=$(grep "Description" "$service" | sed 's/.*for //' | head -1) + status=$(systemctl is-active "$service_name" 2>/dev/null || echo "inactive") + port=$(grep "Environment=PORT=" "$service" | cut -d'=' -f3 | head -1) + + # Check if SSL is enabled by looking for SSL certificate + ssl="HTTP" + if [ -d "/etc/letsencrypt/live/$fqdn" ] || grep -q "ssl_certificate" "/etc/nginx/sites-available/$fqdn" 2>/dev/null; then + ssl="HTTPS" + fi + + # Handle missing port (try alternative method for older services) + if [ -z "$port" ]; then + port=$(grep "PORT=" "$service" | cut -d'=' -f2 | head -1) + fi + if [ -z "$port" ]; then + port="N/A" + fi + + # Color code status (using printf for proper color rendering) + if [ "$status" = "active" ]; then + status_display="$(printf "${GREEN}%-10s${NC}" "$status")" + else + status_display="$(printf "${RED}%-10s${NC}" "$status")" + fi + + # Color code SSL (using printf for proper color rendering) + if [ "$ssl" = "HTTPS" ]; then + ssl_display="$(printf "${GREEN}%-10s${NC}" "$ssl")" + else + ssl_display="$(printf "${YELLOW}%-10s${NC}" "$ssl")" + fi + + printf "%-30s %s %-15s %s %-15s\n" "$fqdn" "$status_display" "$port" "$ssl_display" "$service_name" + fi + done + + echo "" + print_info "Management Commands:" + echo " ./manage-patchmon.sh status - Show detailed status" + echo " ./manage-patchmon.sh update - Update instance" + echo " ./manage-patchmon.sh delete - Delete instance" + echo "" + print_info "Instance Management:" + echo " cd /opt/patchmon-/ && ./manage.sh status - Local management" + echo " systemctl status patchmon- - Service status" + echo " journalctl -u patchmon- -f - Live logs" +} + +# Show instance status +show_status() { + local fqdn=$1 + + if [ $# -ne 1 ]; then + print_error "Usage: $0 status " + exit 1 + fi + + local app_dir="/opt/patchmon-$fqdn" + local service_name="patchmon-$fqdn" + + if [ ! -d "$app_dir" ]; then + print_error "Instance for $fqdn not found" + exit 1 + fi + + print_info "Status for $fqdn:" + + echo -e "${BLUE}Service Status:${NC}" + systemctl status "$service_name" --no-pager + + echo -e "\n${BLUE}Recent Logs:${NC}" + journalctl -u "$service_name" --no-pager -n 20 + + echo -e "\n${BLUE}Disk Usage:${NC}" + du -sh "$app_dir" + + echo -e "\n${BLUE}Database Size:${NC}" + local db_safe_name=$(echo $fqdn | tr '[:upper:]' '[:lower:]' | tr '.-' '__') + local db_name="patchmon_${db_safe_name}" + sudo -u postgres psql -c "SELECT pg_size_pretty(pg_database_size('$db_name'));" +} + +# Show help +show_help() { + echo -e "${BLUE}PatchMon Unified Management System${NC}" + echo "" + echo "Usage: $0 [options]" + echo "" + echo "Commands:" + echo " deploy " + echo " Deploy a new PatchMon instance with smart system detection" + echo " - Auto-generates all credentials" + echo " - Detects existing components (PostgreSQL, Nginx, Node.js)" + echo " - Assigns unique ports automatically" + echo " - Creates isolated Python virtual environments" + echo "" + echo " update " + echo " Update an existing instance with latest code" + echo "" + echo " delete " + echo " Delete an instance completely" + echo "" + echo " list" + echo " List all instances with ports and status" + echo "" + echo " status " + echo " Show detailed status of a specific instance" + echo "" + echo "Examples:" + echo " $0 deploy customer1.patchmon.com https://github.com/yourorg/patchmon" + echo " $0 deploy internal.company.com https://github.com/yourorg/patchmon" + echo " $0 update customer1.patchmon.com" + echo " $0 list" + echo " $0 status customer1.patchmon.com" + echo "" + echo "Features:" + echo " ✅ Smart component detection (skips already installed)" + echo " ✅ Automatic port allocation (prevents conflicts)" + echo " ✅ Isolated Node.js environments per instance" + echo " ✅ FQDN-based database and folder naming" + echo " ✅ Interactive SSL setup (Let's Encrypt or HTTP)" + echo " ✅ Complete instance isolation" + echo " ✅ Automatic credential generation" +} + +# Main execution +case $1 in + "deploy") + shift + deploy_instance "$@" + ;; + "update") + update_instance "$2" + ;; + "delete") + delete_instance "$2" + ;; + "list") + list_instances + ;; + "status") + show_status "$2" + ;; + "help"|"-h"|"--help") + show_help + ;; + *) + print_error "Unknown command: $1" + show_help + exit 1 + ;; +esac diff --git a/setup-admin-user.js b/setup-admin-user.js index d4b73ff..f1f569e 100644 --- a/setup-admin-user.js +++ b/setup-admin-user.js @@ -70,9 +70,8 @@ async function setupAdminUser() { data: { username: username.trim(), email: email.trim(), - passwordHash, - role: 'admin', - isActive: true + passwordHash: passwordHash, + role: 'admin' }, select: { id: true, @@ -92,9 +91,10 @@ async function setupAdminUser() { console.log('\n🎉 Setup complete!'); console.log('\nNext steps:'); - console.log('1. Start the backend server: cd backend && npm start'); - console.log('2. Start the frontend: cd frontend && npm run dev'); - console.log('3. Visit http://localhost:3000 and login with your credentials'); + console.log('1. The backend server is already running as a systemd service'); + console.log('2. The frontend is already built and served by Nginx'); + console.log('3. Visit https://' + process.env.FQDN + ' and login with your credentials'); + console.log('4. Use the management script: ./manage.sh {status|restart|logs|update|backup|credentials|reset-admin}'); } catch (error) { console.error('❌ Error setting up admin user:', error);