From 4a25fc56ce5d2b9dafecd20f9f441d1e491ee76e Mon Sep 17 00:00:00 2001 From: smashingtags <48292010+smashingtags@users.noreply.github.com> Date: Sun, 20 Jul 2025 11:18:52 -0400 Subject: [PATCH 1/5] overhaul of auth and more --- .dockerignore | 54 +- .dockerignore.backend | 54 ++ .dockerignore.frontend | 47 ++ .env.docker | 10 + .env.production | 24 + AUTHENTICATION.md | 177 +++++ DEPLOYMENT.md | 210 ++++++ DOCKER-TESTING.md | 172 +++++ Dockerfile | 22 +- Dockerfile.backend | 14 +- docker-compose.yml | 15 +- package.json | 28 +- server/auth.js | 373 ++++++++++ server/index.js | 951 +++++++++++++++++++++++--- src/App.tsx | 273 +++++--- src/components/AuthStatus.tsx | 23 + src/components/ContainerControls.tsx | 64 +- src/components/DeployModal.tsx | 42 +- src/components/DeploymentProgress.tsx | 86 +++ src/components/LoginModal.tsx | 135 ++++ src/components/PortManager.tsx | 137 ++++ src/components/UserMenu.tsx | 93 +++ src/components/UserSettings.tsx | 200 ++++++ src/contexts/AuthContext.tsx | 135 ++++ src/contexts/NotificationContext.tsx | 155 +++++ src/hooks/useLoading.ts | 30 + src/lib/api.ts | 76 +- src/lib/config.ts | 2 +- src/lib/validation.ts | 47 +- src/main.tsx | 8 +- test-docker.ps1 | 75 ++ test-docker.sh | 66 ++ tsconfig.json | 34 +- vite.config.ts | 1 + 34 files changed, 3566 insertions(+), 267 deletions(-) create mode 100644 .dockerignore.backend create mode 100644 .dockerignore.frontend create mode 100644 .env.docker create mode 100644 .env.production create mode 100644 AUTHENTICATION.md create mode 100644 DEPLOYMENT.md create mode 100644 DOCKER-TESTING.md create mode 100644 server/auth.js create mode 100644 src/components/AuthStatus.tsx create mode 100644 src/components/DeploymentProgress.tsx create mode 100644 src/components/LoginModal.tsx create mode 100644 src/components/PortManager.tsx create mode 100644 src/components/UserMenu.tsx create mode 100644 src/components/UserSettings.tsx create mode 100644 src/contexts/AuthContext.tsx create mode 100644 src/contexts/NotificationContext.tsx create mode 100644 src/hooks/useLoading.ts create mode 100644 test-docker.ps1 create mode 100644 test-docker.sh diff --git a/.dockerignore b/.dockerignore index 249c8fbc8..727e2c450 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,13 +1,45 @@ -node_modules -npm-debug.log -.git -.gitignore +# Dependencies +node_modules/ +npm-debug.log* + +# Build outputs +dist/ +build/ + +# Development files .env -.env.* -*.md -.vscode -dist -build -coverage +.env.local +.env.development +.env.test +*.log + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files .DS_Store -.vs/ +Thumbs.db + +# Git +.git/ +.gitignore + +# Documentation (not needed in container) +*.md +docs/ + +# Test files +test/ +tests/ +*.test.js +*.spec.js + +# Backend files not needed in frontend container +# server/ - removed to allow backend builds + +# Temporary files +tmp/ +temp/ \ No newline at end of file diff --git a/.dockerignore.backend b/.dockerignore.backend new file mode 100644 index 000000000..39ed64ec2 --- /dev/null +++ b/.dockerignore.backend @@ -0,0 +1,54 @@ +# Backend-specific .dockerignore + +# Dependencies +node_modules/ +npm-debug.log* + +# Build outputs +dist/ +build/ + +# Development files +.env +.env.local +.env.development +.env.test +*.log + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Git +.git/ +.gitignore + +# Documentation +*.md +docs/ + +# Test files +test/ +tests/ +*.test.js +*.spec.js + +# Frontend files not needed in backend container +src/ +public/ +index.html +vite.config.ts +tsconfig*.json +tailwind.config.js +postcss.config.js +eslint.config.js + +# Temporary files +tmp/ +temp/ \ No newline at end of file diff --git a/.dockerignore.frontend b/.dockerignore.frontend new file mode 100644 index 000000000..41ca98dfd --- /dev/null +++ b/.dockerignore.frontend @@ -0,0 +1,47 @@ +# Frontend-specific .dockerignore + +# Dependencies +node_modules/ +npm-debug.log* + +# Build outputs +dist/ +build/ + +# Development files +.env +.env.local +.env.development +.env.test +*.log + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Git +.git/ +.gitignore + +# Documentation +*.md +docs/ + +# Test files +test/ +tests/ +*.test.js +*.spec.js + +# Backend files not needed in frontend container +server/ + +# Temporary files +tmp/ +temp/ \ No newline at end of file diff --git a/.env.docker b/.env.docker new file mode 100644 index 000000000..0ad291753 --- /dev/null +++ b/.env.docker @@ -0,0 +1,10 @@ +# Docker Environment Configuration +# Copy this to .env for Docker Compose deployment + +# Authentication +JWT_SECRET=homelabarr-super-secret-jwt-key-change-this-in-production +DEFAULT_ADMIN_PASSWORD=admin + +# Optional: Override default ports +# FRONTEND_PORT=8087 +# BACKEND_PORT=3009 \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 000000000..e6e3114d2 --- /dev/null +++ b/.env.production @@ -0,0 +1,24 @@ +# Production Environment Configuration +NODE_ENV=production + +# Backend Configuration +PORT=3001 +CORS_ORIGIN=http://localhost:8087 + +# Docker Configuration +DOCKER_SOCKET=/var/run/docker.sock + +# Security Configuration +AUTH_ENABLED=true +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production +JWT_EXPIRES_IN=24h +DEFAULT_ADMIN_PASSWORD=admin + +# Logging Configuration +LOG_LEVEL=info + +# Data Storage +DATA_PATH=/app/data + +# Network Configuration +DEFAULT_NETWORK=homelabarr \ No newline at end of file diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md new file mode 100644 index 000000000..1786b6f54 --- /dev/null +++ b/AUTHENTICATION.md @@ -0,0 +1,177 @@ +# HomelabARR Authentication System + +## Overview + +HomelabARR includes a comprehensive authentication system to secure your container management interface. The system uses JWT tokens for stateless authentication and bcrypt for secure password hashing. + +## Features + +### 🔐 Security Features +- **JWT-based authentication** - Stateless, secure token-based auth +- **Bcrypt password hashing** - Industry-standard password security +- **Role-based access control** - Admin and user roles +- **Automatic token validation** - Expired tokens are handled gracefully +- **Secure session management** - Automatic logout on token expiration + +### 👤 User Management +- **Default admin account** - Created automatically on first run +- **Password management** - Users can change their own passwords +- **User profiles** - Username, email, role, last login tracking +- **Admin controls** - Admins can manage other users (future feature) + +### đŸ›Ąī¸ Access Control +- **Protected endpoints** - All container operations require authentication +- **Optional authentication** - Can be disabled for development +- **Role-based permissions** - Different access levels for admin vs user +- **CORS protection** - Restricted origins in production + +## Default Credentials + +**âš ī¸ IMPORTANT: Change these immediately after first login!** + +- **Username:** `admin` +- **Password:** `admin` + +## Configuration + +### Environment Variables + +```bash +# Enable/disable authentication +AUTH_ENABLED=true + +# JWT configuration +JWT_SECRET=your-super-secret-key-change-this +JWT_EXPIRES_IN=24h + +# Default admin password (only used on first setup) +DEFAULT_ADMIN_PASSWORD=admin +``` + +### Docker Compose + +```yaml +environment: + - AUTH_ENABLED=true + - JWT_SECRET=${JWT_SECRET:-homelabarr-change-this-secret} + - JWT_EXPIRES_IN=24h + - DEFAULT_ADMIN_PASSWORD=${DEFAULT_ADMIN_PASSWORD:-admin} +``` + +## API Endpoints + +### Authentication Routes + +- `POST /api/auth/login` - User login +- `GET /api/auth/me` - Get current user info +- `POST /api/auth/change-password` - Change user password +- `POST /api/auth/register` - Create new user (admin only) +- `GET /api/auth/users` - List all users (admin only) + +### Protected Routes + +All container management endpoints require authentication when `AUTH_ENABLED=true`: + +- `/api/containers/*` - Container operations +- `/api/deploy` - Application deployment +- `/api/ports/*` - Port management + +## Frontend Components + +### Authentication UI +- **LoginModal** - Secure login form with validation +- **UserMenu** - User profile dropdown with logout +- **UserSettings** - Password change and user management +- **AuthStatus** - Authentication status indicator + +### Security Features +- **Automatic token refresh** - Handles expired tokens gracefully +- **Secure storage** - Tokens stored in localStorage with validation +- **Error handling** - Clear feedback for authentication errors +- **Loading states** - Visual feedback during auth operations + +## Security Best Practices + +### Production Deployment +1. **Change default credentials** immediately +2. **Use strong JWT secret** (32+ random characters) +3. **Set secure CORS origins** (no wildcards) +4. **Use HTTPS** in production +5. **Regular password updates** for all users + +### Password Requirements +- Minimum 6 characters (configurable) +- Bcrypt hashing with salt rounds: 12 +- No password reuse validation (future feature) + +### Token Security +- JWT tokens expire after 24 hours (configurable) +- Tokens include user ID, username, and role +- Automatic cleanup of expired tokens +- No refresh token implementation (stateless design) + +## Troubleshooting + +### Common Issues + +1. **"Authentication required" errors** + - Check if `AUTH_ENABLED=true` in environment + - Verify JWT_SECRET is set + - Ensure user is logged in + +2. **"Invalid token" errors** + - Token may have expired (24h default) + - JWT_SECRET may have changed + - Clear browser storage and re-login + +3. **Default admin not created** + - Check server logs for errors + - Verify write permissions to `server/config/` + - Ensure no existing users.json file + +### Debug Commands + +```bash +# Check authentication status +curl http://localhost:3001/health + +# Test login +curl -X POST http://localhost:3001/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin"}' + +# Check user info (with token) +curl http://localhost:3001/auth/me \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" +``` + +## Future Enhancements + +- **Multi-factor authentication** (2FA/TOTP) +- **OAuth integration** (Google, GitHub, etc.) +- **Advanced user management** (admin UI) +- **Audit logging** (user actions tracking) +- **Password policies** (complexity requirements) +- **Session management** (active sessions, force logout) +- **API key authentication** (for automation) + +## Development + +### Disabling Authentication + +For development, you can disable authentication: + +```bash +AUTH_ENABLED=false +``` + +This allows unrestricted access to all endpoints. + +### Testing Authentication + +The system includes comprehensive error handling and validation. Test with: + +- Invalid credentials +- Expired tokens +- Missing tokens +- Role-based access restrictions \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 000000000..5108f01ff --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,210 @@ +# HomelabARR Deployment Guide + +## Quick Start (Recommended) + +### Using Docker Compose + +1. **Clone the repository:** + ```bash + git clone https://github.com/smashingtags/homelabarr.git + cd homelabarr + ``` + +2. **Start the application:** + ```bash + docker compose up -d + ``` + +3. **Access the application:** + - Frontend: http://localhost:8087 + - Backend API: http://localhost:3009 + +## Production Deployment + +### Prerequisites + +- Docker Engine 20.10.0+ +- Docker Compose v2.0.0+ +- Linux host (recommended) +- Minimum 2GB RAM +- 10GB free disk space + +### Environment Configuration + +1. **Copy environment template:** + ```bash + cp .env.example .env + ``` + +2. **Configure environment variables:** + ```bash + # Edit .env file + NODE_ENV=production + CORS_ORIGIN=https://your-domain.com + + # Authentication (recommended for production) + AUTH_ENABLED=true + JWT_SECRET=your-super-secret-jwt-key-change-this + DEFAULT_ADMIN_PASSWORD=your-secure-admin-password + ``` + +### First Time Setup + +After deployment, you'll need to sign in: + +1. **Access the application:** http://localhost:8087 +2. **Click "Sign In"** in the top right +3. **Use default credentials:** + - Username: `admin` + - Password: `admin` (or your custom password) +4. **âš ī¸ IMMEDIATELY change the default password** via Settings → Change Password + +### Authentication Features + +- **Secure JWT-based authentication** +- **Role-based access control** (admin/user roles) +- **Password management** - users can change their own passwords +- **Session management** - automatic logout on token expiration +- **Admin user management** - create and manage users (coming soon) + +### Security Considerations + +#### Authentication Security +HomelabARR includes built-in authentication to protect your container management: + +- **JWT-based authentication** with configurable expiration +- **Secure password hashing** using bcrypt +- **Role-based access control** (admin/user permissions) +- **Automatic session management** and token validation + +#### Docker Socket Security +The application requires access to the Docker socket for container management. This is configured securely by: + +- Using Docker group membership instead of privileged mode +- Restricting CORS origins in production +- Authentication required for all container operations +- Health checks for service monitoring + +#### Network Security +- Frontend and backend communicate over internal Docker network +- Only necessary ports are exposed +- CORS is configured for specific origins only + +### Monitoring & Health Checks + +Both services include health checks: + +- **Frontend**: HTTP check on port 80 +- **Backend**: Docker connectivity check on `/health` + +Monitor with: +```bash +docker compose ps +docker compose logs -f +``` + +### Backup & Data Persistence + +Application data is stored in: +- Container configurations: `/app/data/` +- Docker volumes: Managed by deployed applications + +**Backup strategy:** +```bash +# Backup application data +docker compose exec backend tar -czf /tmp/backup.tar.gz /app/data/ + +# Backup Docker volumes (for deployed apps) +docker run --rm -v homelabarr_data:/data -v $(pwd):/backup alpine tar -czf /backup/volumes-backup.tar.gz /data +``` + +### Troubleshooting + +#### Common Issues + +1. **Port conflicts:** + - Check if ports 8087, 3009 are available + - Use `netstat -tulpn | grep :8087` to check + +2. **Docker socket permission denied:** + ```bash + sudo chmod 666 /var/run/docker.sock + # Or add user to docker group: + sudo usermod -aG docker $USER + ``` + +3. **Container won't start:** + ```bash + docker compose logs backend + docker compose logs frontend + ``` + +#### Health Check Commands + +```bash +# Check backend health +curl http://localhost:3009/health + +# Check frontend +curl http://localhost:8087 + +# Check Docker connectivity +docker compose exec backend node -e "console.log('Backend accessible')" +``` + +### Updating + +```bash +# Pull latest changes +git pull origin main + +# Rebuild and restart +docker compose down +docker compose up -d --build +``` + +### Performance Tuning + +For production environments: + +1. **Resource limits** (add to docker-compose.yml): + ```yaml + services: + backend: + deploy: + resources: + limits: + memory: 512M + cpus: '0.5' + ``` + +2. **Log rotation:** + ```yaml + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + ``` + +## Development Setup + +For development with hot reload: + +```bash +# Install dependencies +npm install + +# Start development servers +npm run dev +``` + +This starts: +- Frontend: http://localhost:8080 +- Backend: http://localhost:3001 + +## Support + +- GitHub Issues: [Report bugs](https://github.com/smashingtags/homelabarr/issues) +- Documentation: [Wiki](https://github.com/smashingtags/homelabarr/wiki) +- Discord: [Community chat](https://discord.gg/Pc7mXX786x) \ No newline at end of file diff --git a/DOCKER-TESTING.md b/DOCKER-TESTING.md new file mode 100644 index 000000000..0e547124f --- /dev/null +++ b/DOCKER-TESTING.md @@ -0,0 +1,172 @@ +# Docker Testing Guide + +## Pre-Flight Checklist ✅ + +Before running the Docker containers, ensure: + +### System Requirements +- [ ] Docker Desktop is installed and running +- [ ] Docker Compose is available (`docker-compose --version`) +- [ ] Ports 8087 and 3009 are available +- [ ] At least 2GB RAM available +- [ ] 5GB free disk space + +### Quick Test Commands + +```powershell +# Test Docker +docker --version +docker info + +# Test Docker Compose +docker-compose --version + +# Check available ports +netstat -an | findstr ":8087" +netstat -an | findstr ":3009" +``` + +## Testing Steps + +### Option 1: Automated Test (Recommended) +```powershell +# Run the automated test script +.\test-docker.ps1 +``` + +### Option 2: Manual Testing +```powershell +# 1. Create environment file +copy .env.docker .env + +# 2. Build and start containers +docker-compose down --remove-orphans +docker-compose build --no-cache +docker-compose up -d + +# 3. Check container status +docker-compose ps + +# 4. View logs +docker-compose logs -f +``` + +## Expected Results + +### Successful Deployment +- ✅ Frontend accessible at http://localhost:8087 +- ✅ Backend API at http://localhost:3009/health +- ✅ Both containers show "healthy" status +- ✅ Login page appears with authentication + +### Health Check URLs +- Frontend: http://localhost:8087/health +- Backend: http://localhost:3009/health +- Full App: http://localhost:8087 + +### Default Credentials +- Username: `admin` +- Password: `admin` +- **âš ī¸ Change immediately after first login!** + +## Troubleshooting + +### Common Issues + +1. **Port conflicts** + ```powershell + # Check what's using the ports + netstat -ano | findstr ":8087" + netstat -ano | findstr ":3009" + ``` + +2. **Docker not running** + ```powershell + # Start Docker Desktop + # Wait for Docker to fully start + docker info + ``` + +3. **Build failures** + ```powershell + # Clean build + docker-compose down --volumes --remove-orphans + docker system prune -f + docker-compose build --no-cache + ``` + +4. **Container startup issues** + ```powershell + # Check logs + docker-compose logs backend + docker-compose logs frontend + ``` + +### Debug Commands + +```powershell +# Container status +docker-compose ps + +# Live logs +docker-compose logs -f + +# Execute commands in containers +docker-compose exec backend node -e "console.log('Backend OK')" +docker-compose exec frontend nginx -t + +# Check internal networking +docker-compose exec backend ping homelabarr-frontend +docker-compose exec frontend ping homelabarr-backend +``` + +## Testing Authentication + +1. **Access the app**: http://localhost:8087 +2. **Click "Sign In"** in top right +3. **Use default credentials**: admin / admin +4. **Verify login success**: Should see user menu +5. **Test container operations**: Try viewing deployed apps +6. **Change password**: Settings → Change Password + +## Performance Testing + +```powershell +# Check resource usage +docker stats + +# Test API endpoints +curl http://localhost:3009/health +curl http://localhost:3009/containers -H "Authorization: Bearer YOUR_TOKEN" +``` + +## Cleanup + +```powershell +# Stop containers +docker-compose down + +# Remove everything (including volumes) +docker-compose down --volumes --remove-orphans + +# Clean up Docker system +docker system prune -f +``` + +## Success Criteria + +- [ ] Both containers start successfully +- [ ] Health checks pass +- [ ] Frontend loads without errors +- [ ] Authentication works +- [ ] API endpoints respond correctly +- [ ] No critical errors in logs +- [ ] Resource usage is reasonable (<1GB RAM total) + +## Next Steps After Successful Test + +1. **Change default password** +2. **Configure environment variables** for production +3. **Set up reverse proxy** (optional) +4. **Configure backups** for user data +5. **Monitor logs** for any issues \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 151e47911..fd9b120a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,8 +8,19 @@ WORKDIR /app COPY package*.json ./ RUN npm install +# Copy configuration files +COPY tsconfig*.json ./ +COPY vite.config.ts ./ +COPY tailwind.config.js ./ +COPY postcss.config.js ./ +COPY eslint.config.js ./ +COPY index.html ./ + # Copy source code -COPY . . +COPY src/ ./src/ + +# Create public directory (Vite expects it) +RUN mkdir -p public # Build the application RUN npm run build @@ -40,6 +51,11 @@ RUN mkdir -p /var/cache/nginx \ /var/log/nginx \ /usr/share/nginx/html -# Expose port 80 +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 -# Start nginx \ No newline at end of file +# Start nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/Dockerfile.backend b/Dockerfile.backend index 78bdc175b..8811c5d9b 100644 --- a/Dockerfile.backend +++ b/Dockerfile.backend @@ -7,11 +7,12 @@ WORKDIR /app COPY package*.json ./ RUN npm install --omit=dev -# Copy server files -COPY . . +# Copy server files only +COPY server/ ./server/ # Create required directories -RUN mkdir -p server/templates server/config server/backups +RUN mkdir -p server/templates server/config server/data server/backups && \ + chown -R node:node server/config server/data server/backups # Add docker group and add node user to it RUN addgroup -g 998 dockergrp && \ @@ -20,5 +21,12 @@ RUN addgroup -g 998 dockergrp && \ # Expose port EXPOSE 3001 +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3001/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))" + +# Switch to non-root user +USER node + # Start the server CMD ["node", "server/index.js"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3df768641..94c1e60bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: frontend: - build: . + build: + context: . + dockerfile: Dockerfile container_name: homelabarr-frontend restart: unless-stopped ports: @@ -23,17 +25,22 @@ services: environment: - NODE_ENV=production - PORT=3001 - - CORS_ORIGIN=* + - CORS_ORIGIN=http://localhost:8087,http://homelabarr-frontend,http://localhost:8088 - DOCKER_SOCKET=/var/run/docker.sock + - AUTH_ENABLED=true + - JWT_SECRET=${JWT_SECRET:-homelabarr-change-this-secret} + - JWT_EXPIRES_IN=24h + - DEFAULT_ADMIN_PASSWORD=${DEFAULT_ADMIN_PASSWORD:-admin} volumes: - /var/run/docker.sock:/var/run/docker.sock ports: - - "3009:3001" + - "8088:3001" networks: - homelabarr group_add: - "999" # Docker group ID - privileged: true # Required for Docker socket access + # Remove privileged mode for better security + # privileged: true healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://homelabarr-backend:3001/health"] interval: 30s diff --git a/package.json b/package.json index b0f42dff9..69af242c6 100644 --- a/package.json +++ b/package.json @@ -5,35 +5,37 @@ "type": "module", "scripts": { "dev": "concurrently \"vite\" \"node server/index.js\"", - "build": "tsc && vite build", + "build": "tsc --project tsconfig.app.json --noEmit && vite build", "start": "node server/index.js", "lint": "eslint .", "preview": "vite preview" }, "dependencies": { - "lucide-react": "^0.344.0", - "react": "^18.3.1", + "bcryptjs": "^3.0.2", "chart.js": "^4.4.1", - "react-chartjs-2": "^5.2.0", - "react-dom": "^18.3.1", - "express": "^4.18.3", - "dockerode": "^4.0.2", "cors": "^2.8.5", - "yaml": "^2.4.1", + "dockerode": "^4.0.2", + "express": "^4.18.3", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", + "jsonwebtoken": "^9.0.2", + "lucide-react": "^0.344.0", + "react": "^18.3.1", + "react-chartjs-2": "^5.2.0", + "react-dom": "^18.3.1", "react-markdown": "^9.0.1", "react-query": "^3.39.3", - "winston": "^3.11.0" + "winston": "^3.11.0", + "yaml": "^2.4.1" }, "devDependencies": { "@eslint/js": "^9.9.1", + "@types/cors": "^2.8.17", + "@types/dockerode": "^3.3.23", + "@types/express": "^4.17.21", "@types/node": "^20.11.24", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", - "@types/express": "^4.17.21", - "@types/dockerode": "^3.3.23", - "@types/cors": "^2.8.17", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.18", "concurrently": "^8.2.2", @@ -47,4 +49,4 @@ "typescript-eslint": "^8.3.0", "vite": "^5.4.2" } -} \ No newline at end of file +} diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 000000000..ba6e47c94 --- /dev/null +++ b/server/auth.js @@ -0,0 +1,373 @@ +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcryptjs'; +import fs from 'fs'; +import path from 'path'; + +// Configuration +const JWT_SECRET = process.env.JWT_SECRET || 'homelabarr-default-secret-change-in-production'; +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '24h'; +const USERS_FILE = path.join(process.cwd(), 'server', 'config', 'users.json'); + +// Ensure config directory exists +const configDir = path.dirname(USERS_FILE); +if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); +} + +// Default admin user (only created if no users exist) +const DEFAULT_ADMIN = { + id: 'admin', + username: 'admin', + email: 'admin@homelabarr.local', + role: 'admin', + createdAt: new Date().toISOString(), + lastLogin: null +}; + +// User management functions +export function loadUsers() { + try { + if (!fs.existsSync(USERS_FILE)) { + return []; + } + const data = fs.readFileSync(USERS_FILE, 'utf8'); + return JSON.parse(data); + } catch (error) { + console.error('Error loading users:', error); + return []; + } +} + +export function saveUsers(users) { + try { + fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2)); + return true; + } catch (error) { + console.error('Error saving users:', error); + return false; + } +} + +export function findUserByUsername(username) { + const users = loadUsers(); + return users.find(user => user.username === username); +} + +export function findUserById(id) { + const users = loadUsers(); + return users.find(user => user.id === id); +} + +export async function createUser(userData) { + const users = loadUsers(); + + // Check if username already exists + if (users.find(user => user.username === userData.username)) { + throw new Error('Username already exists'); + } + + // Hash password + const hashedPassword = await bcrypt.hash(userData.password, 12); + + const newUser = { + id: generateUserId(), + username: userData.username, + email: userData.email || '', + role: userData.role || 'user', + password: hashedPassword, + createdAt: new Date().toISOString(), + lastLogin: null + }; + + users.push(newUser); + saveUsers(users); + + // Return user without password + const { password, ...userWithoutPassword } = newUser; + return userWithoutPassword; +} + +export async function validatePassword(username, password) { + const user = findUserByUsername(username); + if (!user) { + return null; + } + + const isValid = await bcrypt.compare(password, user.password); + if (!isValid) { + return null; + } + + // Update last login + const users = loadUsers(); + const userIndex = users.findIndex(u => u.id === user.id); + if (userIndex !== -1) { + users[userIndex].lastLogin = new Date().toISOString(); + saveUsers(users); + } + + // Return user without password + const { password: _, ...userWithoutPassword } = user; + return userWithoutPassword; +} + +export function generateToken(user) { + return jwt.sign( + { + id: user.id, + username: user.username, + role: user.role + }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN } + ); +} + +export function verifyToken(token) { + try { + return jwt.verify(token, JWT_SECRET); + } catch (error) { + return null; + } +} + +export function generateUserId() { + return 'user_' + Math.random().toString(36).substr(2, 9) + Date.now().toString(36); +} + +// Session management +const SESSIONS_FILE = path.join(process.cwd(), 'server', 'data', 'sessions.json'); + +export function loadSessions() { + try { + if (!fs.existsSync(SESSIONS_FILE)) { + return []; + } + const data = fs.readFileSync(SESSIONS_FILE, 'utf8'); + return JSON.parse(data); + } catch (error) { + console.error('Error loading sessions:', error); + return []; + } +} + +export function saveSessions(sessions) { + try { + fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions, null, 2)); + } catch (error) { + console.error('Error saving sessions:', error); + } +} + +export function getUserSessions(userId) { + const sessions = loadSessions(); + return sessions.filter(session => session.userId === userId && !session.invalidated); +} + +export function invalidateSession(sessionId) { + const sessions = loadSessions(); + const sessionIndex = sessions.findIndex(s => s.id === sessionId); + if (sessionIndex !== -1) { + sessions[sessionIndex].invalidated = true; + saveSessions(sessions); + } +} + +// Authentication function +export async function authenticate(username, password) { + try { + const user = await validatePassword(username, password); + if (!user) { + return { success: false, error: 'Invalid username or password' }; + } + + const token = generateToken(user); + const sessionId = 'session_' + Math.random().toString(36).substr(2, 9) + Date.now().toString(36); + + // Create session + const sessions = loadSessions(); + const session = { + id: sessionId, + userId: user.id, + token: token, + createdAt: new Date().toISOString(), + lastActivity: new Date().toISOString(), + userAgent: '', + ipAddress: '', + invalidated: false + }; + + sessions.push(session); + saveSessions(sessions); + + return { + success: true, + user: user, + token: token, + sessionId: sessionId + }; + } catch (error) { + console.error('Authentication error:', error); + return { success: false, error: 'Authentication failed' }; + } +} + +// Change password function +export async function changePassword(userId, currentPassword, newPassword) { + try { + const user = findUserById(userId); + if (!user) { + return { success: false, error: 'User not found' }; + } + + // Verify current password + const isCurrentValid = await bcrypt.compare(currentPassword, user.password); + if (!isCurrentValid) { + return { success: false, error: 'Current password is incorrect' }; + } + + // Hash new password + const hashedNewPassword = await bcrypt.hash(newPassword, 10); + + // Update user + const users = loadUsers(); + const userIndex = users.findIndex(u => u.id === userId); + if (userIndex !== -1) { + users[userIndex].password = hashedNewPassword; + saveUsers(users); + return { success: true }; + } + + return { success: false, error: 'Failed to update password' }; + } catch (error) { + console.error('Change password error:', error); + return { success: false, error: 'Failed to change password' }; + } +} + +// Initialize default admin user if no users exist +export async function initializeAuth() { + const users = loadUsers(); + + if (users.length === 0) { + console.log('🔐 No users found, creating default admin user...'); + + // Create default admin with password 'admin' (should be changed immediately) + const defaultPassword = process.env.DEFAULT_ADMIN_PASSWORD || 'admin'; + + try { + await createUser({ + ...DEFAULT_ADMIN, + password: defaultPassword + }); + + console.log('✅ Default admin user created:'); + console.log(' Username: admin'); + console.log(' Password: admin'); + console.log(' âš ī¸ CHANGE THE DEFAULT PASSWORD IMMEDIATELY!'); + } catch (error) { + console.error('❌ Failed to create default admin user:', error); + } + } +} + +// Middleware functions +export function requireAuth(role) { + // If called with parameters (req, res, next), it's being used as direct middleware + if (arguments.length === 3 || (arguments.length === 1 && typeof role === 'object')) { + const [req, res, next] = arguments.length === 3 ? arguments : [role, arguments[1], arguments[2]]; + + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + error: 'Authentication required', + details: 'Please provide a valid authentication token' + }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + + if (!decoded) { + return res.status(401).json({ + error: 'Invalid token', + details: 'Authentication token is invalid or expired' + }); + } + + // Add user info to request + req.user = decoded; + next(); + return; + } + + // If called with no arguments or a role, return a middleware function + return (req, res, next) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + error: 'Authentication required', + details: 'Please provide a valid authentication token' + }); + } + + const token = authHeader.substring(7); + const decoded = verifyToken(token); + + if (!decoded) { + return res.status(401).json({ + error: 'Invalid token', + details: 'Authentication token is invalid or expired' + }); + } + + // Check role if specified + if (role && decoded.role !== role && decoded.role !== 'admin') { + return res.status(403).json({ + error: 'Insufficient permissions', + details: `Role '${role}' required` + }); + } + + // Add user info to request + req.user = decoded; + next(); + }; +} + +export function requireRole(role) { + return (req, res, next) => { + if (!req.user) { + return res.status(401).json({ + error: 'Authentication required' + }); + } + + if (req.user.role !== role && req.user.role !== 'admin') { + return res.status(403).json({ + error: 'Insufficient permissions', + details: `This action requires ${role} role or higher` + }); + } + + next(); + }; +} + +// Optional authentication middleware (allows both authenticated and unauthenticated access) +export function optionalAuth(req, res, next) { + const authHeader = req.headers.authorization; + + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + const decoded = verifyToken(token); + + if (decoded) { + req.user = decoded; + } + } + + next(); +} \ No newline at end of file diff --git a/server/index.js b/server/index.js index 5a47c5d76..3eb8bbc4f 100644 --- a/server/index.js +++ b/server/index.js @@ -8,74 +8,531 @@ import helmet from 'helmet'; import os from 'os'; import { chmodSync } from 'fs'; import { promisify } from 'util'; +import { + initializeAuth, + requireAuth, + requireRole, + optionalAuth, + validatePassword, + generateToken, + createUser, + loadUsers, + saveUsers, + findUserById, + authenticate, + loadSessions, + saveSessions, + getUserSessions, + invalidateSession, + changePassword +} from './auth.js'; + +// Environment configuration +const isDevelopment = process.env.NODE_ENV !== 'production'; +const logLevel = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info'); +const authEnabled = process.env.AUTH_ENABLED !== 'false'; // Default to enabled + +// Simple logging utility +const logger = { + info: (message, ...args) => console.log(`â„šī¸ ${message}`, ...args), + warn: (message, ...args) => console.warn(`âš ī¸ ${message}`, ...args), + error: (message, ...args) => console.error(`❌ ${message}`, ...args), + debug: (message, ...args) => { + if (isDevelopment) console.log(`🐛 ${message}`, ...args); + } +}; const mkdir = promisify(fs.mkdir); const chmod = promisify(fs.chmod); // CORS configuration +const allowedOrigins = process.env.CORS_ORIGIN + ? process.env.CORS_ORIGIN.split(',').map(origin => origin.trim()) + : ['http://localhost:8080', 'http://localhost:3000']; + const corsOptions = { - origin: '*', // Allow all origins in development + origin: (origin, callback) => { + // Allow requests with no origin (like mobile apps or curl requests) + if (!origin) return callback(null, true); + + if (allowedOrigins.includes(origin)) { + callback(null, true); + } else { + console.warn(`CORS blocked request from origin: ${origin}`); + callback(new Error('Not allowed by CORS')); + } + }, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization', 'Accept', 'Access-Control-Allow-Origin'], - exposedHeaders: ['Access-Control-Allow-Origin'], + allowedHeaders: ['Content-Type', 'Authorization', 'Accept'], credentials: true, optionsSuccessStatus: 200 }; const app = express(); +// Middleware setup +app.use(express.json()); +app.use(cors(corsOptions)); +app.use(helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, +})); + +// Authentication routes +app.post('/auth/login', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username and password required' }); + } + + const result = await authenticate(username, password); + + if (!result.success) { + return res.status(401).json({ error: result.error }); + } + + // Update session with request info + const sessions = loadSessions(); + const session = sessions.find(s => s.id === result.sessionId); + if (session) { + session.userAgent = req.headers['user-agent'] || ''; + session.ipAddress = req.ip || req.connection.remoteAddress || ''; + saveSessions(sessions); + } + + logger.info(`User ${result.user.username} logged in from ${req.ip}`); + + res.json({ + success: true, + user: result.user, + token: result.token + }); + } catch (error) { + logger.error('Login error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.post('/auth/logout', requireAuth(), (req, res) => { + try { + const authHeader = req.headers.authorization; + const token = authHeader && authHeader.split(' ')[1]; + + if (token) { + // Find and invalidate the session + const sessions = loadSessions(); + const session = sessions.find(s => s.token === token); + if (session) { + invalidateSession(session.id); + } + } + + logger.info(`User ${req.user.username} logged out`); + res.json({ success: true }); + } catch (error) { + logger.error('Logout error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/auth/me', requireAuth(), (req, res) => { + const users = loadUsers(); + const user = users.find(u => u.id === req.user.id); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json({ + id: user.id, + username: user.username, + email: user.email, + role: user.role, + createdAt: user.createdAt + }); +}); + +app.post('/auth/change-password', requireAuth(), async (req, res) => { + try { + const { currentPassword, newPassword } = req.body; + + if (!currentPassword || !newPassword) { + return res.status(400).json({ error: 'Current and new password required' }); + } + + if (newPassword.length < 6) { + return res.status(400).json({ error: 'New password must be at least 6 characters' }); + } + + const result = await changePassword(req.user.id, currentPassword, newPassword); + + if (!result.success) { + return res.status(400).json({ error: result.error }); + } + + logger.info(`User ${req.user.username} changed password`); + res.json({ success: true }); + } catch (error) { + logger.error('Change password error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/auth/sessions', requireAuth(), (req, res) => { + const sessions = getUserSessions(req.user.id); + const sanitizedSessions = sessions.map(session => ({ + id: session.id, + createdAt: session.createdAt, + lastActivity: session.lastActivity, + userAgent: session.userAgent, + ipAddress: session.ipAddress + })); + + res.json(sanitizedSessions); +}); + +app.delete('/auth/sessions/:sessionId', requireAuth(), (req, res) => { + const { sessionId } = req.params; + const sessions = getUserSessions(req.user.id); + const session = sessions.find(s => s.id === sessionId); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + invalidateSession(sessionId); + logger.info(`User ${req.user.username} invalidated session ${sessionId}`); + + res.json({ success: true }); +}); + +// Admin-only user management routes +app.post('/auth/users', requireAuth('admin'), async (req, res) => { + try { + const { username, email, password, role } = req.body; + + if (!username || !email || !password) { + return res.status(400).json({ error: 'Username, email, and password required' }); + } + + const result = await createUser({ username, email, password, role }); + + if (!result.success) { + return res.status(400).json({ error: result.error }); + } + + logger.info(`Admin ${req.user.username} created user ${result.user.username}`); + res.json(result); + } catch (error) { + logger.error('Create user error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +app.get('/auth/users', requireAuth('admin'), (req, res) => { + const users = loadUsers(); + const sanitizedUsers = users.map(user => ({ + id: user.id, + username: user.username, + email: user.email, + role: user.role, + createdAt: user.createdAt, + updatedAt: user.updatedAt + })); + + res.json(sanitizedUsers); +}); + // Basic health check endpoint -app.get('/health', (req, res) => { - res.status(200).send('OK'); +app.get('/health', async (req, res) => { + try { + // Test Docker connection + const containers = await docker.listContainers({ limit: 1 }); + + res.status(200).json({ + status: 'OK', + platform: 'linux', + docker: 'connected', + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || '1.0.0' + }); + } catch (error) { + res.status(503).json({ + status: 'ERROR', + platform: 'linux', + docker: 'disconnected', + error: error.message, + timestamp: new Date().toISOString() + }); + } }); -let docker; +// Authentication routes +app.post('/auth/login', async (req, res) => { + try { + const { username, password } = req.body; -// Configure Docker connection based on platform -try { - // Try to set socket permissions if we're on Linux - if (os.platform() === 'linux') { - try { - chmodSync('/var/run/docker.sock', 0o666); - } catch (err) { - console.warn('Could not set Docker socket permissions:', err.message); + if (!username || !password) { + return res.status(400).json({ + error: 'Missing credentials', + details: 'Username and password are required' + }); + } + + const user = await validatePassword(username, password); + if (!user) { + return res.status(401).json({ + error: 'Invalid credentials', + details: 'Username or password is incorrect' + }); } + + const token = generateToken(user); + + res.json({ + success: true, + token, + user: { + id: user.id, + username: user.username, + email: user.email, + role: user.role, + lastLogin: user.lastLogin + } + }); + } catch (error) { + logger.error('Login error:', error); + res.status(500).json({ + error: 'Login failed', + details: error.message + }); } +}); + +app.post('/auth/register', requireAuth, requireRole('admin'), async (req, res) => { + try { + const { username, password, email, role } = req.body; - if (os.platform() === 'win32') { - docker = new Docker({ - host: '127.0.0.1', - port: 2375 + if (!username || !password) { + return res.status(400).json({ + error: 'Missing required fields', + details: 'Username and password are required' + }); + } + + const user = await createUser({ + username, + password, + email: email || '', + role: role || 'user' }); - } else { - docker = new Docker({ - socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock', - timeout: 30000 + + res.json({ + success: true, + user + }); + } catch (error) { + logger.error('Registration error:', error); + res.status(400).json({ + error: 'Registration failed', + details: error.message }); } -} catch (error) { - console.error('Error connecting to Docker:', error); - docker = { - listContainers: async () => [], - getContainer: () => ({ - stats: async () => ({}), - inspect: async () => ({}) - }) - }; -} +}); -// Security middleware -app.use(helmet({ - contentSecurityPolicy: false, - crossOriginEmbedderPolicy: false, - crossOriginResourcePolicy: false -})); +app.get('/auth/me', requireAuth, (req, res) => { + const user = findUserById(req.user.id); + if (!user) { + return res.status(404).json({ + error: 'User not found' + }); + } -// Apply CORS middleware -app.use(cors(corsOptions)); + const { password, ...userWithoutPassword } = user; + res.json({ + success: true, + user: userWithoutPassword + }); +}); +app.get('/auth/users', requireAuth, requireRole('admin'), (req, res) => { + const users = loadUsers(); + const usersWithoutPasswords = users.map(({ password, ...user }) => user); -app.use(express.json()); + res.json({ + success: true, + users: usersWithoutPasswords + }); +}); + +app.post('/auth/change-password', requireAuth, async (req, res) => { + try { + const { currentPassword, newPassword } = req.body; + + if (!currentPassword || !newPassword) { + return res.status(400).json({ + error: 'Missing required fields', + details: 'Current password and new password are required' + }); + } + + const user = findUserById(req.user.id); + if (!user) { + return res.status(404).json({ + error: 'User not found' + }); + } + + // Validate current password + const validUser = await validatePassword(user.username, currentPassword); + if (!validUser) { + return res.status(401).json({ + error: 'Invalid current password' + }); + } + + // Update password + const bcrypt = await import('bcryptjs'); + const hashedPassword = await bcrypt.hash(newPassword, 12); + + const users = loadUsers(); + const userIndex = users.findIndex(u => u.id === user.id); + if (userIndex !== -1) { + users[userIndex].password = hashedPassword; + saveUsers(users); + } + + res.json({ + success: true, + message: 'Password changed successfully' + }); + } catch (error) { + logger.error('Password change error:', error); + res.status(500).json({ + error: 'Failed to change password', + details: error.message + }); + } +}); + +// Template validation endpoint +app.get('/templates/validate', (req, res) => { + try { + const templateDir = path.join(process.cwd(), 'server', 'templates'); + const templateFiles = fs.readdirSync(templateDir) + .filter(file => file.endsWith('.yml')) + .map(file => file.replace('.yml', '')); + + res.json({ + success: true, + availableTemplates: templateFiles.sort(), + count: templateFiles.length + }); + } catch (error) { + console.error('Error reading templates:', error); + res.status(500).json({ + error: 'Failed to read templates', + details: error.message + }); + } +}); + +// Port availability check endpoint +app.get('/ports/check', async (req, res) => { + try { + const containers = await docker.listContainers({ all: true }); + const usedPorts = new Set(); + + containers.forEach(container => { + if (container.Ports) { + container.Ports.forEach(port => { + if (port.PublicPort) { + usedPorts.add(port.PublicPort); + } + }); + } + }); + + res.json({ + success: true, + usedPorts: Array.from(usedPorts).sort((a, b) => a - b) + }); + } catch (error) { + console.error('Error checking ports:', error); + res.status(500).json({ + error: 'Failed to check port availability', + details: error.message + }); + } +}); + +// Find available port endpoint +app.get('/ports/available', async (req, res) => { + try { + const startPort = parseInt(req.query.start) || 8000; + const endPort = parseInt(req.query.end) || 9000; + + const containers = await docker.listContainers({ all: true }); + const usedPorts = new Set(); + + containers.forEach(container => { + if (container.Ports) { + container.Ports.forEach(port => { + if (port.PublicPort) { + usedPorts.add(port.PublicPort); + } + }); + } + }); + + // Find first available port in range + for (let port = startPort; port <= endPort; port++) { + if (!usedPorts.has(port)) { + return res.json({ + success: true, + availablePort: port + }); + } + } + + res.status(404).json({ + error: 'No available ports found in range', + details: `Checked ports ${startPort}-${endPort}` + }); + } catch (error) { + console.error('Error finding available port:', error); + res.status(500).json({ + error: 'Failed to find available port', + details: error.message + }); + } +}); + +let docker; + +// Configure Docker connection for Linux deployments +try { + // Set socket permissions for Docker access + try { + chmodSync('/var/run/docker.sock', 0o666); + } catch (err) { + console.warn('Could not set Docker socket permissions:', err.message); + } + + docker = new Docker({ + socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock', + timeout: 30000 + }); + + logger.info('Docker client initialized for Linux deployment'); +} catch (error) { + logger.error('Error initializing Docker client:', error); + process.exit(1); +} + +// Middleware already set up at the top of the file // Helper functions function calculateCPUPercentage(stats) { @@ -91,7 +548,15 @@ function calculateCPUPercentage(stats) { return 0; } - return (cpuDelta / systemDelta) * cpuCount * 100; + const percentage = (cpuDelta / systemDelta) * cpuCount * 100; + + // Ensure we return a valid number + if (isNaN(percentage) || !isFinite(percentage)) { + return 0; + } + + // Cap at 100% per core * number of cores (reasonable maximum) + return Math.min(percentage, cpuCount * 100); } function calculateMemoryUsage(stats) { @@ -103,13 +568,15 @@ function calculateMemoryUsage(stats) { }; } - const usage = stats.memory_stats.usage - (stats.memory_stats.stats?.cache || 0); - const limit = stats.memory_stats.limit; + const usage = Math.max(0, stats.memory_stats.usage - (stats.memory_stats.stats?.cache || 0)); + const limit = stats.memory_stats.limit || 1; // Prevent division by zero + + const percentage = (usage / limit) * 100; return { usage, limit, - percentage: (usage / limit) * 100 + percentage: isNaN(percentage) || !isFinite(percentage) ? 0 : Math.min(percentage, 100) }; } @@ -131,23 +598,72 @@ function calculateUptime(container) { if (!container.State || !container.State.StartedAt) { return 0; } - + const startTime = new Date(container.State.StartedAt).getTime(); const now = new Date().getTime(); return Math.floor((now - startTime) / 1000); } -// Routes -app.get('/containers', async (req, res) => { +// Middleware to conditionally require auth +const conditionalAuth = (req, res, next) => { + if (!authEnabled) { + return next(); + } + return requireAuth(req, res, next); +}; + +// Routes (protected by authentication if enabled) +app.get('/containers', conditionalAuth, async (req, res) => { try { const containers = await docker.listContainers({ all: true }); + const includeStats = req.query.stats === 'true'; + + if (!includeStats) { + // Fast path: return containers without expensive stats + const containersWithBasicInfo = await Promise.all( + containers.map(async (container) => { + try { + const containerInfo = docker.getContainer(container.Id); + const info = await containerInfo.inspect(); + + return { + ...container, + stats: { + cpu: 0, + memory: { usage: 0, limit: 0, percentage: 0 }, + network: {}, + uptime: calculateUptime(info) + }, + config: info.Config, + mounts: info.Mounts + }; + } catch (error) { + console.error(`Error fetching basic info for container ${container.Id}:`, error); + return { + ...container, + stats: { + cpu: 0, + memory: { usage: 0, limit: 0, percentage: 0 }, + network: {}, + uptime: 0 + } + }; + } + }) + ); + return res.json(containersWithBasicInfo); + } + + // Slow path: include full statistics (only when requested) const containersWithStats = await Promise.all( containers.map(async (container) => { try { const containerInfo = docker.getContainer(container.Id); - const stats = await containerInfo.stats({ stream: false }); - const info = await containerInfo.inspect(); - + const [stats, info] = await Promise.all([ + containerInfo.stats({ stream: false }), + containerInfo.inspect() + ]); + return { ...container, stats: { @@ -161,7 +677,16 @@ app.get('/containers', async (req, res) => { }; } catch (error) { console.error(`Error fetching stats for container ${container.Id}:`, error); - return container; + // Return container with default stats instead of failing + return { + ...container, + stats: { + cpu: 0, + memory: { usage: 0, limit: 0, percentage: 0 }, + network: {}, + uptime: 0 + } + }; } }) ); @@ -172,51 +697,247 @@ app.get('/containers', async (req, res) => { } }); +// Separate endpoint for container statistics +app.get('/containers/:id/stats', async (req, res) => { + try { + const containerInfo = docker.getContainer(req.params.id); + const [stats, info] = await Promise.all([ + containerInfo.stats({ stream: false }), + containerInfo.inspect() + ]); + + res.json({ + success: true, + stats: { + cpu: calculateCPUPercentage(stats), + memory: calculateMemoryUsage(stats), + network: calculateNetworkUsage(stats), + uptime: calculateUptime(info) + } + }); + } catch (error) { + console.error(`Error fetching stats for container ${req.params.id}:`, error); + res.status(500).json({ + error: 'Failed to fetch container statistics', + details: error.message + }); + } +}); + +// Container control endpoints +app.post('/containers/:id/start', conditionalAuth, async (req, res) => { + try { + const container = docker.getContainer(req.params.id); + await container.start(); + res.json({ success: true, message: 'Container started successfully' }); + } catch (error) { + console.error('Error starting container:', error); + res.status(500).json({ + error: 'Failed to start container', + details: error.message + }); + } +}); + +app.post('/containers/:id/stop', conditionalAuth, async (req, res) => { + try { + const container = docker.getContainer(req.params.id); + await container.stop(); + res.json({ success: true, message: 'Container stopped successfully' }); + } catch (error) { + console.error('Error stopping container:', error); + res.status(500).json({ + error: 'Failed to stop container', + details: error.message + }); + } +}); + +app.post('/containers/:id/restart', conditionalAuth, async (req, res) => { + try { + const container = docker.getContainer(req.params.id); + await container.restart(); + res.json({ success: true, message: 'Container restarted successfully' }); + } catch (error) { + console.error('Error restarting container:', error); + res.status(500).json({ + error: 'Failed to restart container', + details: error.message + }); + } +}); + +app.delete('/containers/:id', conditionalAuth, async (req, res) => { + try { + const container = docker.getContainer(req.params.id); + + // Stop container if it's running + try { + const info = await container.inspect(); + if (info.State.Running) { + await container.stop(); + } + } catch (stopError) { + console.warn('Container may already be stopped:', stopError.message); + } + + // Remove the container + await container.remove(); + res.json({ success: true, message: 'Container removed successfully' }); + } catch (error) { + console.error('Error removing container:', error); + res.status(500).json({ + error: 'Failed to remove container', + details: error.message + }); + } +}); + +app.get('/containers/:id/logs', async (req, res) => { + try { + const container = docker.getContainer(req.params.id); + const tail = parseInt(req.query.tail) || 100; + + const logs = await container.logs({ + stdout: true, + stderr: true, + tail: tail, + timestamps: true + }); + + // Convert buffer to string and clean up Docker log format + const logString = logs.toString('utf8'); + const cleanLogs = logString + .split('\n') + .map(line => { + // Remove Docker's 8-byte header from each log line + if (line.length > 8) { + return line.substring(8); + } + return line; + }) + .filter(line => line.trim().length > 0) + .join('\n'); + + res.json({ + success: true, + logs: cleanLogs, + containerId: req.params.id + }); + } catch (error) { + console.error('Error fetching container logs:', error); + res.status(500).json({ + error: 'Failed to fetch container logs', + details: error.message + }); + } +}); + // Deploy container endpoint -app.post('/deploy', async (req, res) => { +app.post('/deploy', authEnabled ? requireAuth : optionalAuth, async (req, res) => { try { - const { appId, config } = req.body; - console.log('Deploying app:', appId, 'with config:', config); - + const { appId, config, mode } = req.body; + console.log(`🚀 Starting deployment of ${appId}...`); + + // Validate input + if (!appId) { + return res.status(400).json({ error: 'App ID is required' }); + } + + if (!config || typeof config !== 'object') { + return res.status(400).json({ error: 'Configuration object is required' }); + } + // Read template file const templatePath = path.join(process.cwd(), 'server', 'templates', `${appId}.yml`); if (!fs.existsSync(templatePath)) { console.error('Template not found:', templatePath); - return res.status(404).json({ error: 'Template not found' }); + return res.status(404).json({ + error: 'Template not found', + details: `No template file found for app: ${appId}` + }); } const templateContent = fs.readFileSync(templatePath, 'utf8'); console.log('Template content:', templateContent); - + const template = yaml.parse(templateContent); console.log('Parsed template:', template); // Replace variables in template const composerConfig = JSON.stringify(template) .replace(/\${([^}]+)}/g, (_, key) => config[key] || ''); - + // Parse back to object const finalConfig = JSON.parse(composerConfig); console.log('Final config:', finalConfig); - // Ensure the homelabarr network exists + // Check for port conflicts before deployment + const [serviceName, serviceConfig] = Object.entries(finalConfig.services)[0]; + if (serviceConfig.ports) { + const containers = await docker.listContainers({ all: true }); + const usedPorts = new Set(); + + containers.forEach(container => { + if (container.Ports) { + container.Ports.forEach(port => { + if (port.PublicPort) { + usedPorts.add(port.PublicPort); + } + }); + } + }); + + const conflictingPorts = []; + serviceConfig.ports.forEach(portMapping => { + const cleanMapping = portMapping.replace('/udp', ''); + const [hostPort] = cleanMapping.split(':').reverse(); + const port = parseInt(hostPort); + + if (usedPorts.has(port)) { + conflictingPorts.push(port); + } + }); + + if (conflictingPorts.length > 0) { + return res.status(409).json({ + error: 'Port conflict detected', + details: `The following ports are already in use: ${conflictingPorts.join(', ')}`, + conflictingPorts + }); + } + } + + // Ensure required networks exist try { const networks = await docker.listNetworks(); - const networkExists = networks.some(n => n.Name === 'homelabarr'); - if (!networkExists) { + + // Create homelabarr network if it doesn't exist + const homelabarrExists = networks.some(n => n.Name === 'homelabarr'); + if (!homelabarrExists) { console.log('Creating homelabarr network'); await docker.createNetwork({ Name: 'homelabarr', Driver: 'bridge' }); } + + // Create proxy network if it doesn't exist (for templates that use it) + const proxyExists = networks.some(n => n.Name === 'proxy'); + if (!proxyExists) { + console.log('Creating proxy network'); + await docker.createNetwork({ + Name: 'proxy', + Driver: 'bridge' + }); + } } catch (error) { - console.error('Error checking/creating network:', error); - throw new Error('Failed to setup network'); + console.error('Error checking/creating networks:', error); + throw new Error('Failed to setup networks'); } - // Get the service configuration - const [serviceName, serviceConfig] = Object.entries(finalConfig.services)[0]; + // Get the service configuration (reusing variables from above) + // const [serviceName, serviceConfig] = Object.entries(finalConfig.services)[0]; // Already declared above // Pull the image first console.log('Pulling image:', serviceConfig.image); @@ -230,42 +951,74 @@ app.post('/deploy', async (req, res) => { throw new Error(`Failed to pull image: ${error.message}`); } + // Process environment variables (handle both array and object formats) + let envVars = []; + if (serviceConfig.environment) { + if (Array.isArray(serviceConfig.environment)) { + envVars = serviceConfig.environment; + } else { + envVars = Object.entries(serviceConfig.environment).map(([key, value]) => `${key}=${value}`); + } + } + + // Process volumes with proper path handling + const processedVolumes = (serviceConfig.volumes || []).map(volume => { + const [host, container, options] = volume.split(':'); + + // Handle relative paths and special cases + let hostPath; + if (host.startsWith('./')) { + // Create app-specific config directory + hostPath = path.join(process.cwd(), 'data', appId, host.substring(2)); + } else if (host.startsWith('/')) { + // Absolute path - use as is + hostPath = host; + } else { + // Relative path - create in app data directory + hostPath = path.join(process.cwd(), 'data', appId, host); + } + + // Ensure directory exists + try { + if (!fs.existsSync(hostPath)) { + fs.mkdirSync(hostPath, { recursive: true }); + fs.chmodSync(hostPath, 0o755); + } + } catch (error) { + console.error(`Error creating volume path ${hostPath}:`, error); + throw new Error(`Failed to create volume path: ${error.message}`); + } + + return options ? `${hostPath}:${container}:${options}` : `${hostPath}:${container}`; + }); + // Create container config const containerConfig = { Image: serviceConfig.image, name: serviceConfig.container_name, - Env: Object.entries(serviceConfig.environment || {}).map(([key, value]) => `${key}=${value}`), + Env: envVars, HostConfig: { RestartPolicy: { - Name: serviceConfig.restart || 'no', + Name: serviceConfig.restart === 'unless-stopped' ? 'unless-stopped' : 'no', }, - Binds: (serviceConfig.volumes || []).map(volume => { - const [host, container] = volume.split(':'); - // Ensure host path exists - try { - const hostPath = path.resolve(host); - if (!fs.existsSync(hostPath)) { - fs.mkdirSync(hostPath, { recursive: true }); - fs.chmodSync(hostPath, 0o755); - } - return `${hostPath}:${container}`; - } catch (error) { - console.error(`Error creating volume path ${host}:`, error); - throw new Error(`Failed to create volume path: ${error.message}`); - } - }), + Binds: processedVolumes, PortBindings: {}, - NetworkMode: 'homelabarr', + NetworkMode: 'proxy', // Use proxy network to match templates }, ExposedPorts: {} }; - // Handle port bindings + // Handle port bindings with UDP support if (serviceConfig.ports) { serviceConfig.ports.forEach(portMapping => { - const [hostPort, containerPort] = portMapping.split(':').reverse(); - containerConfig.ExposedPorts[`${containerPort}/tcp`] = {}; - containerConfig.HostConfig.PortBindings[`${containerPort}/tcp`] = [ + // Handle both TCP and UDP ports + const isUdp = portMapping.includes('/udp'); + const cleanMapping = portMapping.replace('/udp', ''); + const [hostPort, containerPort] = cleanMapping.split(':').reverse(); + const protocol = isUdp ? 'udp' : 'tcp'; + + containerConfig.ExposedPorts[`${containerPort}/${protocol}`] = {}; + containerConfig.HostConfig.PortBindings[`${containerPort}/${protocol}`] = [ { HostPort: hostPort } ]; }); @@ -278,7 +1031,7 @@ app.post('/deploy', async (req, res) => { try { // Check if container with same name exists const existingContainers = await docker.listContainers({ all: true }); - const existing = existingContainers.find(c => + const existing = existingContainers.find(c => c.Names.includes(`/${containerConfig.name}`) ); @@ -313,10 +1066,10 @@ app.post('/deploy', async (req, res) => { throw new Error(`Failed to start container: ${error.message}`); } + console.log(`✅ Successfully deployed ${appId} (${container.id})`); res.json({ success: true, containerId: container.id }); } catch (error) { - console.error('Error deploying container:', error); - console.error('Stack trace:', error.stack); + console.error(`❌ Failed to deploy ${appId}:`, error.message); res.status(500).json({ error: 'Failed to deploy container', details: error.message, @@ -336,8 +1089,16 @@ app.use((err, req, res, next) => { // Start server const PORT = process.env.PORT || 3001; -app.listen(PORT, '0.0.0.0', () => { // Listen on all interfaces - console.log(`Server running on port ${PORT}`); - console.log(`Running on platform: ${os.platform()}`); - console.log('Docker connection mode:', os.platform() === 'win32' ? 'Windows TCP' : 'Unix Socket'); +// Initialize authentication system +initializeAuth().then(() => { + app.listen(PORT, '0.0.0.0', () => { + logger.info(`HomelabARR backend running on port ${PORT}`); + logger.info('Configured for Linux Docker deployments'); + logger.info(`Environment: ${process.env.NODE_ENV || 'development'}`); + logger.info(`Authentication: ${authEnabled ? 'enabled' : 'disabled'}`); + logger.info(`CORS origins: ${allowedOrigins.join(', ')}`); + }); +}).catch(error => { + logger.error('Failed to initialize authentication:', error); + process.exit(1); }); \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 0910837ab..4601a6f7d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,14 @@ import { LogViewer } from './components/LogViewer'; import { ThemeToggle } from './components/ThemeToggle'; import { HelpModal } from './components/HelpModal'; import { Leaderboard } from './components/Leaderboard'; -import { +import { PortManager } from './components/PortManager'; +import { useNotifications } from './contexts/NotificationContext'; +import { useLoading } from './hooks/useLoading'; +import { useAuth } from './contexts/AuthContext'; +import { LoginModal } from './components/LoginModal'; +import { UserMenu } from './components/UserMenu'; +import { UserSettings } from './components/UserSettings'; +import { LayoutGrid, Network, Box, @@ -23,7 +30,8 @@ import { FileText, MessageSquare, HelpCircle, - Trophy + Trophy, + RefreshCw } from 'lucide-react'; import { deployApp, getContainers } from './lib/api'; @@ -49,27 +57,55 @@ export default function App() { const [activeCategory, setActiveCategory] = useState('all'); const [sortField, setSortField] = useState<'name' | 'status' | 'deployedAt' | 'uptime'>('name'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); - const [error, setError] = useState(null); const [selectedContainerLogs, setSelectedContainerLogs] = useState(null); const [helpModalOpen, setHelpModalOpen] = useState(false); - const [deploymentInProgress, setDeploymentInProgress] = useState(false); + const [portManagerOpen, setPortManagerOpen] = useState(false); + const [loginModalOpen, setLoginModalOpen] = useState(false); + const [settingsModalOpen, setSettingsModalOpen] = useState(false); - const filteredApps = appTemplates.filter(app => + const { success, error: showError, info } = useNotifications(); + const { loading: deploymentInProgress, withLoading } = useLoading(); + const { isAuthenticated } = useAuth(); + + const filteredApps = appTemplates.filter(app => (activeCategory === 'all' || app.category === activeCategory) && - (searchQuery === '' || + (searchQuery === '' || app.name.toLowerCase().includes(searchQuery.toLowerCase()) || app.description.toLowerCase().includes(searchQuery.toLowerCase())) ); useEffect(() => { - fetchContainers(); - const interval = setInterval(fetchContainers, 5000); - return () => clearInterval(interval); - }, []); + // Only fetch containers if user is authenticated + if (!isAuthenticated) { + return; + } + + fetchContainers(false); // Fast initial load without stats + + // Set up intervals: fast refresh for basic info, slower for stats + const basicInterval = setInterval(() => fetchContainers(false), 10000); // Every 10s + const statsInterval = setInterval(() => { + if (activeCategory === 'deployed') { + fetchContainers(true); // Only fetch stats when viewing deployed apps + } + }, 30000); // Every 30s + + return () => { + clearInterval(basicInterval); + clearInterval(statsInterval); + }; + }, [activeCategory, isAuthenticated]); + + // Show helpful info when switching to deployed apps + useEffect(() => { + if (activeCategory === 'deployed' && deployedApps.length === 0) { + info('No Deployed Apps', 'Deploy some applications to see them here. Browse the categories above to get started!'); + } + }, [activeCategory, deployedApps.length]); - const fetchContainers = async () => { + const fetchContainers = async (includeStats = false) => { try { - const containers = await getContainers(); + const containers = await getContainers(includeStats); const apps = containers.map((container: any) => ({ id: container.Id, name: container.Names[0].replace('/', ''), @@ -80,31 +116,55 @@ export default function App() { })); setDeployedApps(apps); } catch (err) { - setError('Failed to fetch containers'); + showError('Failed to fetch containers', 'Unable to connect to Docker or retrieve container information'); } }; const handleDeploy = async (config: Record, mode: DeploymentMode) => { if (!selectedApp) return; - - setDeploymentInProgress(true); - setError(null); - - try { - await deployApp(selectedApp.id, config, mode); - await fetchContainers(); - setSelectedApp(null); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to deploy application'; - setError(errorMessage); - } finally { - setDeploymentInProgress(false); - } + + info('Deployment Started', `Deploying ${selectedApp.name}...`); + + await withLoading( + async () => { + await deployApp(selectedApp.id, config, mode); + await fetchContainers(); + return selectedApp.name; + }, + (appName) => { + success('Deployment Successful', `${appName} has been deployed successfully!`); + setSelectedApp(null); + // Switch to deployed apps view to see the new container + setActiveCategory('deployed'); + }, + (err) => { + let errorTitle = 'Deployment Failed'; + let errorMessage = 'Failed to deploy application'; + + if (err.message.includes('Port conflict')) { + errorTitle = 'Port Conflict'; + errorMessage = 'The required ports are already in use. Please stop conflicting containers or choose different ports.'; + } else if (err.message.includes('Template not found')) { + errorTitle = 'Template Missing'; + errorMessage = 'This application template is not available. Please try a different application.'; + } else if (err.message.includes('Docker not available')) { + errorTitle = 'Docker Unavailable'; + errorMessage = 'Docker is not running or accessible. Please ensure Docker is started and try again.'; + } else if (err.message.includes('Failed to pull image')) { + errorTitle = 'Image Pull Failed'; + errorMessage = 'Unable to download the application image. Please check your internet connection.'; + } else { + errorMessage = err.message; + } + + showError(errorTitle, errorMessage); + } + ); }; const sortedDeployedApps = [...deployedApps].sort((a, b) => { const direction = sortDirection === 'asc' ? 1 : -1; - + switch (sortField) { case 'name': return direction * a.name.localeCompare(b.name); @@ -138,66 +198,70 @@ export default function App() { return (
-
- - + - + - + +
+
@@ -234,6 +298,14 @@ export default function App() {

Homelabarr

+ + + {isAuthenticated ? ( + setSettingsModalOpen(true)} /> + ) : ( + + )}
- {error && ( -
- {error} -
- )} {/* Search Bar */}
@@ -271,11 +349,10 @@ export default function App() {
diff --git a/src/components/AuthStatus.tsx b/src/components/AuthStatus.tsx new file mode 100644 index 000000000..f760cc5c8 --- /dev/null +++ b/src/components/AuthStatus.tsx @@ -0,0 +1,23 @@ +// import React from 'react'; // Not needed with new JSX transform +import { Shield, ShieldOff } from 'lucide-react'; +import { useAuth } from '../contexts/AuthContext'; + +export function AuthStatus() { + const { isAuthenticated, user } = useAuth(); + + if (!isAuthenticated) { + return ( +
+ + Not Authenticated +
+ ); + } + + return ( +
+ + Authenticated as {user?.username} +
+ ); +} \ No newline at end of file diff --git a/src/components/ContainerControls.tsx b/src/components/ContainerControls.tsx index ea9bbab83..bf880b12d 100644 --- a/src/components/ContainerControls.tsx +++ b/src/components/ContainerControls.tsx @@ -1,5 +1,7 @@ +import { useState } from 'react'; import { Play, Square, RefreshCw, Trash2 } from 'lucide-react'; import { startContainer, stopContainer, restartContainer, removeContainer } from '../lib/api'; +import { useNotifications } from '../contexts/NotificationContext'; interface ContainerControlsProps { containerId: string; @@ -8,12 +10,24 @@ interface ContainerControlsProps { } export function ContainerControls({ containerId, status, onAction }: ContainerControlsProps) { - const handleAction = async (action: () => Promise) => { + const [loadingAction, setLoadingAction] = useState(null); + const { success, error } = useNotifications(); + + const handleAction = async ( + action: () => Promise, + actionName: string, + successMessage: string + ) => { + setLoadingAction(actionName); try { await action(); + success('Action Completed', successMessage); onAction(); - } catch (error) { - console.error('Error performing container action:', error); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; + error(`Failed to ${actionName}`, errorMessage); + } finally { + setLoadingAction(null); } }; @@ -21,35 +35,55 @@ export function ContainerControls({ containerId, status, onAction }: ContainerCo
{status !== 'running' && ( )} {status === 'running' && ( )}
); diff --git a/src/components/DeployModal.tsx b/src/components/DeployModal.tsx index 051d73044..cd52a4b45 100644 --- a/src/components/DeployModal.tsx +++ b/src/components/DeployModal.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { AppTemplate, ConfigField, DeploymentMode } from '../types'; -import { X, Settings2, ChevronDown, ChevronUp } from 'lucide-react'; -import { validateConfig } from '../lib/validation'; +import { X, Settings2, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; +import { validateConfig, validatePortConflicts } from '../lib/validation'; interface DeployModalProps { app: AppTemplate; @@ -14,23 +14,34 @@ export function DeployModal({ app, onClose, onDeploy, loading }: DeployModalProp const [config, setConfig] = useState>({}); const [errors, setErrors] = useState([]); const [showAdvanced, setShowAdvanced] = useState(false); + const [validating, setValidating] = useState(false); const [deploymentMode, setDeploymentMode] = useState({ type: 'standard', useAuthentik: false }); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + setValidating(true); - // Validate configuration - const validationErrors = validateConfig(app, config, showAdvanced); - if (validationErrors.length > 0) { - setErrors(validationErrors); - return; + try { + // Basic validation + const validationErrors = validateConfig(app, config, showAdvanced); + + // Port conflict validation + const portErrors = await validatePortConflicts(app, config); + + const allErrors = [...validationErrors, ...portErrors]; + if (allErrors.length > 0) { + setErrors(allErrors); + return; + } + + setErrors([]); + onDeploy(config, deploymentMode); + } finally { + setValidating(false); } - - setErrors([]); - onDeploy(config, deploymentMode); }; const handleInputChange = (field: ConfigField, value: string) => { @@ -222,10 +233,13 @@ export function DeployModal({ app, onClose, onDeploy, loading }: DeployModalProp
diff --git a/src/components/DeploymentProgress.tsx b/src/components/DeploymentProgress.tsx new file mode 100644 index 000000000..7916a8fb0 --- /dev/null +++ b/src/components/DeploymentProgress.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { CheckCircle, Circle, Loader2 } from 'lucide-react'; + +interface DeploymentStep { + id: string; + label: string; + status: 'pending' | 'active' | 'completed' | 'error'; +} + +interface DeploymentProgressProps { + steps: DeploymentStep[]; + currentStep?: string; +} + +export function DeploymentProgress({ steps }: DeploymentProgressProps) { + return ( +
+ {steps.map((step) => ( +
+
+ {step.status === 'completed' && ( + + )} + {step.status === 'active' && ( + + )} + {step.status === 'pending' && ( + + )} + {step.status === 'error' && ( + + )} +
+
+

+ {step.label} +

+
+
+ ))} +
+ ); +} + +// Hook for managing deployment progress +export function useDeploymentProgress() { + const [steps, setSteps] = React.useState([ + { id: 'validate', label: 'Validating configuration', status: 'pending' }, + { id: 'template', label: 'Processing template', status: 'pending' }, + { id: 'network', label: 'Setting up networks', status: 'pending' }, + { id: 'image', label: 'Pulling container image', status: 'pending' }, + { id: 'container', label: 'Creating container', status: 'pending' }, + { id: 'start', label: 'Starting container', status: 'pending' }, + ]); + + const updateStep = (stepId: string, status: DeploymentStep['status']) => { + setSteps(prev => prev.map(step => + step.id === stepId ? { ...step, status } : step + )); + }; + + const resetSteps = () => { + setSteps(prev => prev.map(step => ({ ...step, status: 'pending' }))); + }; + + const setActiveStep = (stepId: string) => { + setSteps(prev => prev.map(step => ({ + ...step, + status: step.id === stepId ? 'active' : + prev.find(s => s.id === stepId && prev.indexOf(s) > prev.indexOf(step)) ? 'pending' : + step.status === 'active' ? 'completed' : step.status + }))); + }; + + return { + steps, + updateStep, + resetSteps, + setActiveStep + }; +} \ No newline at end of file diff --git a/src/components/LoginModal.tsx b/src/components/LoginModal.tsx new file mode 100644 index 000000000..198a6bc84 --- /dev/null +++ b/src/components/LoginModal.tsx @@ -0,0 +1,135 @@ +import React, { useState } from 'react'; +import { Lock, User, Eye, EyeOff, Loader2 } from 'lucide-react'; +import { useAuth } from '../contexts/AuthContext'; +import { useNotifications } from '../contexts/NotificationContext'; + +interface LoginModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function LoginModal({ isOpen, onClose }: LoginModalProps) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [loading, setLoading] = useState(false); + + const { login } = useAuth(); + const { success, error } = useNotifications(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!username || !password) { + error('Missing Credentials', 'Please enter both username and password'); + return; + } + + setLoading(true); + + try { + await login(username, password); + success('Login Successful', `Welcome back, ${username}!`); + onClose(); + setUsername(''); + setPassword(''); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Login failed'; + error('Login Failed', errorMessage); + } finally { + setLoading(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+
+ +
+
+ +

+ Sign In to HomelabARR +

+ +
+
+ +
+ + setUsername(e.target.value)} + className="w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white" + placeholder="Enter your username" + disabled={loading} + autoComplete="username" + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="w-full pl-10 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white" + placeholder="Enter your password" + disabled={loading} + autoComplete="current-password" + /> + +
+
+ +
+ + +
+
+ +
+

+ Default credentials:
+ Username: admin
+ Password: admin
+ Please change the default password after first login! +

+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/PortManager.tsx b/src/components/PortManager.tsx new file mode 100644 index 000000000..c1adc0b21 --- /dev/null +++ b/src/components/PortManager.tsx @@ -0,0 +1,137 @@ +import { useState, useEffect } from 'react'; +import { Network, RefreshCw, AlertCircle } from 'lucide-react'; +import { checkUsedPorts, findAvailablePort } from '../lib/api'; + +interface PortManagerProps { + isOpen: boolean; + onClose: () => void; +} + +export function PortManager({ isOpen, onClose }: PortManagerProps) { + const [usedPorts, setUsedPorts] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [suggestedPort, setSuggestedPort] = useState(null); + + const fetchUsedPorts = async () => { + setLoading(true); + setError(null); + + try { + const { usedPorts: ports } = await checkUsedPorts(); + setUsedPorts(ports); + } catch (err) { + setError('Failed to fetch port information'); + } finally { + setLoading(false); + } + }; + + const findNextAvailablePort = async () => { + try { + const { availablePort } = await findAvailablePort(8000, 9000); + setSuggestedPort(availablePort); + } catch (err) { + setError('No available ports found in range 8000-9000'); + } + }; + + useEffect(() => { + if (isOpen) { + fetchUsedPorts(); + } + }, [isOpen]); + + if (!isOpen) return null; + + return ( +
+
+
+
+ +

Port Manager

+
+ +
+ + {error && ( +
+ + {error} +
+ )} + +
+ {/* Used Ports Section */} +
+
+

+ Currently Used Ports ({usedPorts.length}) +

+ +
+ + {loading ? ( +
Loading...
+ ) : ( +
+ {usedPorts.map(port => ( + + {port} + + ))} +
+ )} +
+ + {/* Port Finder Section */} +
+

+ Find Available Port +

+
+ + {suggestedPort && ( +
+ Suggested: + + {suggestedPort} + +
+ )} +
+
+ + {/* Port Ranges Info */} +
+

Common Port Ranges:

+

â€ĸ System ports: 1-1023 (requires elevated permissions)

+

â€ĸ User ports: 1024-49151 (safe for most applications)

+

â€ĸ Dynamic ports: 49152-65535 (temporary/ephemeral)

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx new file mode 100644 index 000000000..40005a65f --- /dev/null +++ b/src/components/UserMenu.tsx @@ -0,0 +1,93 @@ +import { useState, useRef, useEffect } from 'react'; +import { User, LogOut, Settings, Shield, ChevronDown } from 'lucide-react'; +import { useAuth } from '../contexts/AuthContext'; +import { useNotifications } from '../contexts/NotificationContext'; + +interface UserMenuProps { + onOpenSettings?: () => void; +} + +export function UserMenu({ onOpenSettings }: UserMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + const { user, logout, isAdmin } = useAuth(); + const { success } = useNotifications(); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleLogout = () => { + logout(); + success('Logged Out', 'You have been successfully logged out'); + setIsOpen(false); + }; + + if (!user) return null; + + return ( +
+ + + {isOpen && ( +
+
+
+
+ {user.username} +
+
+ {user.email || 'No email set'} +
+
+ + {onOpenSettings && ( + + )} + + +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/UserSettings.tsx b/src/components/UserSettings.tsx new file mode 100644 index 000000000..82f70cf48 --- /dev/null +++ b/src/components/UserSettings.tsx @@ -0,0 +1,200 @@ +import React, { useState } from 'react'; +import { X, Key, Users, Shield, Loader2 } from 'lucide-react'; +import { useAuth } from '../contexts/AuthContext'; +import { useNotifications } from '../contexts/NotificationContext'; + +interface UserSettingsProps { + isOpen: boolean; + onClose: () => void; +} + +export function UserSettings({ isOpen, onClose }: UserSettingsProps) { + const [activeTab, setActiveTab] = useState<'password' | 'users'>('password'); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + + const { isAdmin, token } = useAuth(); + const { success, error } = useNotifications(); + + const handlePasswordChange = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!currentPassword || !newPassword) { + error('Missing Fields', 'Please fill in all password fields'); + return; + } + + if (newPassword !== confirmPassword) { + error('Password Mismatch', 'New password and confirmation do not match'); + return; + } + + if (newPassword.length < 6) { + error('Weak Password', 'Password must be at least 6 characters long'); + return; + } + + setLoading(true); + + try { + const response = await fetch('/api/auth/change-password', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + currentPassword, + newPassword, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.details || errorData.error || 'Failed to change password'); + } + + success('Password Changed', 'Your password has been updated successfully'); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to change password'; + error('Password Change Failed', errorMessage); + } finally { + setLoading(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+

User Settings

+ +
+ +
+ + {isAdmin && ( + + )} +
+ +
+ {activeTab === 'password' && ( +
+
+ + setCurrentPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white" + disabled={loading} + autoComplete="current-password" + /> +
+ +
+ + setNewPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white" + disabled={loading} + autoComplete="new-password" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white" + disabled={loading} + autoComplete="new-password" + /> +
+ +
+ +
+
+ )} + + {activeTab === 'users' && isAdmin && ( +
+
+

+ User Management +

+
+ + Admin Only +
+
+ +
+

+ User management features are coming soon! This will include: +

+
    +
  • Create new users
  • +
  • Manage user roles
  • +
  • Reset user passwords
  • +
  • View user activity
  • +
+
+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx new file mode 100644 index 000000000..d4f84080c --- /dev/null +++ b/src/contexts/AuthContext.tsx @@ -0,0 +1,135 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; + +export interface User { + id: string; + username: string; + email: string; + role: 'admin' | 'user'; + lastLogin: string | null; +} + +interface AuthContextType { + user: User | null; + token: string | null; + login: (username: string, password: string) => Promise; + logout: () => void; + isAuthenticated: boolean; + isAdmin: boolean; + loading: boolean; +} + +const AuthContext = createContext(undefined); + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} + +const TOKEN_KEY = 'homelabarr_token'; +const USER_KEY = 'homelabarr_user'; + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [loading, setLoading] = useState(true); + + // Initialize auth state from localStorage + useEffect(() => { + const savedToken = localStorage.getItem(TOKEN_KEY); + const savedUser = localStorage.getItem(USER_KEY); + + if (savedToken && savedUser) { + try { + const parsedUser = JSON.parse(savedUser); + setToken(savedToken); + setUser(parsedUser); + + // Verify token is still valid + verifyToken(savedToken).catch(() => { + // Token is invalid, clear auth state + logout(); + }); + } catch (error) { + console.error('Error parsing saved user data:', error); + logout(); + } + } + + setLoading(false); + }, []); + + const login = async (username: string, password: string) => { + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.details || error.error || 'Login failed'); + } + + const data = await response.json(); + + setToken(data.token); + setUser(data.user); + + localStorage.setItem(TOKEN_KEY, data.token); + localStorage.setItem(USER_KEY, JSON.stringify(data.user)); + } catch (error) { + console.error('Login error:', error); + throw error; + } + }; + + const logout = () => { + setToken(null); + setUser(null); + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + }; + + const verifyToken = async (tokenToVerify: string) => { + try { + const response = await fetch('/api/auth/me', { + headers: { + 'Authorization': `Bearer ${tokenToVerify}`, + }, + }); + + if (!response.ok) { + throw new Error('Token verification failed'); + } + + const data = await response.json(); + setUser(data.user); + return data.user; + } catch (error) { + throw error; + } + }; + + const isAuthenticated = !!user && !!token; + const isAdmin = user?.role === 'admin'; + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/contexts/NotificationContext.tsx b/src/contexts/NotificationContext.tsx new file mode 100644 index 000000000..3ad1f8cbb --- /dev/null +++ b/src/contexts/NotificationContext.tsx @@ -0,0 +1,155 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; +import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'; + +export type NotificationType = 'success' | 'error' | 'warning' | 'info'; + +export interface Notification { + id: string; + type: NotificationType; + title: string; + message?: string; + duration?: number; + action?: { + label: string; + onClick: () => void; + }; +} + +interface NotificationContextType { + notifications: Notification[]; + addNotification: (notification: Omit) => void; + removeNotification: (id: string) => void; + success: (title: string, message?: string) => void; + error: (title: string, message?: string) => void; + warning: (title: string, message?: string) => void; + info: (title: string, message?: string) => void; +} + +const NotificationContext = createContext(undefined); + +export function useNotifications() { + const context = useContext(NotificationContext); + if (!context) { + throw new Error('useNotifications must be used within a NotificationProvider'); + } + return context; +} + +export function NotificationProvider({ children }: { children: React.ReactNode }) { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback((notification: Omit) => { + const id = Math.random().toString(36).substr(2, 9); + const newNotification = { ...notification, id }; + + setNotifications(prev => [...prev, newNotification]); + + // Auto-remove after duration (default 5 seconds) + const duration = notification.duration ?? 5000; + if (duration > 0) { + setTimeout(() => { + removeNotification(id); + }, duration); + } + }, []); + + const removeNotification = useCallback((id: string) => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, []); + + const success = useCallback((title: string, message?: string) => { + addNotification({ type: 'success', title, message }); + }, [addNotification]); + + const error = useCallback((title: string, message?: string) => { + addNotification({ type: 'error', title, message, duration: 8000 }); + }, [addNotification]); + + const warning = useCallback((title: string, message?: string) => { + addNotification({ type: 'warning', title, message, duration: 6000 }); + }, [addNotification]); + + const info = useCallback((title: string, message?: string) => { + addNotification({ type: 'info', title, message }); + }, [addNotification]); + + return ( + + {children} + + + ); +} + +function NotificationContainer() { + const { notifications, removeNotification } = useNotifications(); + + const getIcon = (type: NotificationType) => { + switch (type) { + case 'success': return ; + case 'error': return ; + case 'warning': return ; + case 'info': return ; + } + }; + + const getStyles = (type: NotificationType) => { + switch (type) { + case 'success': return 'bg-green-50 dark:bg-green-900/50 border-green-200 dark:border-green-800'; + case 'error': return 'bg-red-50 dark:bg-red-900/50 border-red-200 dark:border-red-800'; + case 'warning': return 'bg-yellow-50 dark:bg-yellow-900/50 border-yellow-200 dark:border-yellow-800'; + case 'info': return 'bg-blue-50 dark:bg-blue-900/50 border-blue-200 dark:border-blue-800'; + } + }; + + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map((notification) => ( +
+
+
+ {getIcon(notification.type)} +
+
+

+ {notification.title} +

+ {notification.message && ( +

+ {notification.message} +

+ )} + {notification.action && ( + + )} +
+ +
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/src/hooks/useLoading.ts b/src/hooks/useLoading.ts new file mode 100644 index 000000000..a81239a9f --- /dev/null +++ b/src/hooks/useLoading.ts @@ -0,0 +1,30 @@ +import { useState, useCallback } from 'react'; + +export function useLoading(initialState = false) { + const [loading, setLoading] = useState(initialState); + + const withLoading = useCallback(async ( + asyncFn: () => Promise, + onSuccess?: (result: T) => void, + onError?: (error: Error) => void + ): Promise => { + setLoading(true); + try { + const result = await asyncFn(); + onSuccess?.(result); + return result; + } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error'); + onError?.(err); + throw err; + } finally { + setLoading(false); + } + }, []); + + return { + loading, + setLoading, + withLoading + }; +} \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index bbb595ae5..3f226f1dd 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -2,27 +2,77 @@ import { DeploymentMode } from '../types'; const API_BASE_URL = '/api'; // Use relative path for API requests +function getAuthHeaders(): Record { + const token = localStorage.getItem('homelabarr_token'); + return token ? { 'Authorization': `Bearer ${token}` } : {}; +} + async function handleResponse(response: Response) { if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Unknown error occurred' })); + + // Handle authentication errors + if (response.status === 401) { + // Token expired or invalid, clear auth state + localStorage.removeItem('homelabarr_token'); + localStorage.removeItem('homelabarr_user'); + window.location.reload(); // Force re-authentication + } + throw new Error(error.details || error.error || 'Request failed'); } return response.json(); } -export async function getContainers() { - const response = await fetch(`${API_BASE_URL}/containers`); +export async function getContainers(includeStats = false) { + const url = includeStats ? `${API_BASE_URL}/containers?stats=true` : `${API_BASE_URL}/containers`; + const response = await fetch(url, { + headers: getAuthHeaders() + }); return handleResponse(response); } -export async function getContainerLogs(containerId: string, tail: number = 100) { - const response = await fetch(`${API_BASE_URL}/containers/${containerId}/logs?tail=${tail}`); +export async function getContainerStats(containerId: string) { + const response = await fetch(`${API_BASE_URL}/containers/${containerId}/stats`, { + headers: getAuthHeaders() + }); return handleResponse(response); } +export async function getContainerLogs(containerId: string, tail: number = 100) { + const response = await fetch(`${API_BASE_URL}/containers/${containerId}/logs?tail=${tail}`, { + headers: getAuthHeaders() + }); + const data = await handleResponse(response); + + // Parse the logs string into the expected format + if (data.logs) { + const logLines = data.logs.split('\n').filter((line: string) => line.trim()); + return logLines.map((line: string) => { + // Try to extract timestamp if present + const timestampMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)$/); + if (timestampMatch) { + return { + timestamp: new Date(timestampMatch[1]).toLocaleString(), + message: timestampMatch[2] + }; + } + + // If no timestamp, use current time + return { + timestamp: new Date().toLocaleString(), + message: line + }; + }); + } + + return []; +} + export async function startContainer(containerId: string) { const response = await fetch(`${API_BASE_URL}/containers/${containerId}/start`, { method: 'POST', + headers: getAuthHeaders() }); return handleResponse(response); } @@ -30,6 +80,7 @@ export async function startContainer(containerId: string) { export async function stopContainer(containerId: string) { const response = await fetch(`${API_BASE_URL}/containers/${containerId}/stop`, { method: 'POST', + headers: getAuthHeaders() }); return handleResponse(response); } @@ -37,6 +88,7 @@ export async function stopContainer(containerId: string) { export async function restartContainer(containerId: string) { const response = await fetch(`${API_BASE_URL}/containers/${containerId}/restart`, { method: 'POST', + headers: getAuthHeaders() }); return handleResponse(response); } @@ -50,6 +102,7 @@ export async function deployApp( method: 'POST', headers: { 'Content-Type': 'application/json', + ...getAuthHeaders() }, body: JSON.stringify({ appId, @@ -63,6 +116,21 @@ export async function deployApp( export async function removeContainer(containerId: string) { const response = await fetch(`${API_BASE_URL}/containers/${containerId}`, { method: 'DELETE', + headers: getAuthHeaders() + }); + return handleResponse(response); +} + +export async function checkUsedPorts() { + const response = await fetch(`${API_BASE_URL}/ports/check`, { + headers: getAuthHeaders() + }); + return handleResponse(response); +} + +export async function findAvailablePort(startPort: number = 8000, endPort: number = 9000) { + const response = await fetch(`${API_BASE_URL}/ports/available?start=${startPort}&end=${endPort}`, { + headers: getAuthHeaders() }); return handleResponse(response); } diff --git a/src/lib/config.ts b/src/lib/config.ts index f39d4a4bb..65065c0f9 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -71,7 +71,7 @@ export function validatePortConflicts( const errors: string[] = []; const usedPorts = new Set(); - Object.entries(ports).forEach(([service, port]) => { + Object.entries(ports).forEach(([_service, port]) => { if (usedPorts.has(port)) { errors.push(`Port ${port} is already in use by another service`); } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 6c66fa644..31b4286ce 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -28,16 +28,55 @@ export function validateConfig( const field = template.configFields.find(f => f.name === key); if (field?.type === 'number') { const port = parseInt(value, 10); - if (isNaN(port) || port < 1024 || port > 65535) { + if (isNaN(port) || port < 1 || port > 65535) { errors.push(`${field.label} must be a valid port number (1-65535)`); } - // Check for port conflicts - if (template.defaultPorts && Object.values(template.defaultPorts).includes(port)) { - errors.push(`Port ${port} conflicts with a default port`); + // Warn about privileged ports (below 1024) but don't block them + if (port < 1024 && port > 0) { + errors.push(`Warning: Port ${port} is a privileged port and may require elevated permissions`); } } }); + return errors; +} + +import { checkUsedPorts } from './api'; + +// Async validation for port conflicts +export async function validatePortConflicts( + template: AppTemplate, + config: Record +): Promise { + const errors: string[] = []; + + try { + const { usedPorts } = await checkUsedPorts(); + + // Check configured ports against used ports + Object.entries(config).forEach(([key, value]) => { + const field = template.configFields.find(f => f.name === key); + if (field?.type === 'number') { + const port = parseInt(value, 10); + if (usedPorts.includes(port)) { + errors.push(`Port ${port} is already in use by another container`); + } + } + }); + + // Check template default ports + if (template.defaultPorts) { + Object.entries(template.defaultPorts).forEach(([portName, port]) => { + if (usedPorts.includes(port)) { + errors.push(`Default port ${port} (${portName}) is already in use`); + } + }); + } + } catch (error) { + console.warn('Could not check port conflicts:', error); + // Don't block deployment if port check fails + } + return errors; } \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 3f0e413f5..cd84b6a07 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,8 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { ErrorBoundary } from './components/ErrorBoundary'; +import { NotificationProvider } from './contexts/NotificationContext'; +import { AuthProvider } from './contexts/AuthContext'; import App from './App.tsx'; import './index.css'; import { getTheme, setTheme } from './lib/theme'; @@ -11,7 +13,11 @@ setTheme(getTheme()); createRoot(document.getElementById('root')!).render( - + + + + + ); \ No newline at end of file diff --git a/test-docker.ps1 b/test-docker.ps1 new file mode 100644 index 000000000..8a5a3cbcb --- /dev/null +++ b/test-docker.ps1 @@ -0,0 +1,75 @@ +#!/usr/bin/env pwsh + +Write-Host "đŸŗ Testing HomelabARR Docker Deployment" -ForegroundColor Cyan +Write-Host "======================================" -ForegroundColor Cyan + +# Check if Docker is running +try { + docker info | Out-Null + Write-Host "✅ Docker is running" -ForegroundColor Green +} catch { + Write-Host "❌ Docker is not running. Please start Docker and try again." -ForegroundColor Red + exit 1 +} + +# Check if docker-compose is available +try { + docker-compose --version | Out-Null + Write-Host "✅ docker-compose is available" -ForegroundColor Green +} catch { + Write-Host "❌ docker-compose not found. Please install docker-compose." -ForegroundColor Red + exit 1 +} + +# Copy environment file +if (-not (Test-Path .env)) { + Write-Host "📝 Creating .env file from template..." -ForegroundColor Yellow + Copy-Item .env.docker .env +} + +# Build and start containers +Write-Host "🔨 Building and starting containers..." -ForegroundColor Yellow +docker-compose down --remove-orphans +docker-compose build --no-cache +docker-compose up -d + +# Wait for services to start +Write-Host "âŗ Waiting for services to start..." -ForegroundColor Yellow +Start-Sleep -Seconds 15 + +# Check service health +Write-Host "đŸĨ Checking service health..." -ForegroundColor Yellow + +# Check frontend +try { + $response = Invoke-WebRequest -Uri "http://localhost:8087/health" -TimeoutSec 5 + if ($response.StatusCode -eq 200) { + Write-Host "✅ Frontend is healthy (http://localhost:8087)" -ForegroundColor Green + } +} catch { + Write-Host "❌ Frontend health check failed" -ForegroundColor Red +} + +# Check backend +try { + $response = Invoke-WebRequest -Uri "http://localhost:3009/health" -TimeoutSec 5 + if ($response.StatusCode -eq 200) { + Write-Host "✅ Backend is healthy (http://localhost:3009)" -ForegroundColor Green + } +} catch { + Write-Host "❌ Backend health check failed" -ForegroundColor Red +} + +# Show container status +Write-Host "" +Write-Host "📊 Container Status:" -ForegroundColor Cyan +docker-compose ps + +Write-Host "" +Write-Host "🎉 Deployment complete!" -ForegroundColor Green +Write-Host "📱 Frontend: http://localhost:8087" -ForegroundColor Cyan +Write-Host "🔧 Backend API: http://localhost:3009" -ForegroundColor Cyan +Write-Host "🔐 Default login: admin / admin" -ForegroundColor Yellow +Write-Host "" +Write-Host "📋 To view logs: docker-compose logs -f" -ForegroundColor Gray +Write-Host "🛑 To stop: docker-compose down" -ForegroundColor Gray \ No newline at end of file diff --git a/test-docker.sh b/test-docker.sh new file mode 100644 index 000000000..76a35ee5a --- /dev/null +++ b/test-docker.sh @@ -0,0 +1,66 @@ +#!/bin/bash + +echo "đŸŗ Testing HomelabARR Docker Deployment" +echo "======================================" + +# Check if Docker is running +if ! docker info > /dev/null 2>&1; then + echo "❌ Docker is not running. Please start Docker and try again." + exit 1 +fi + +# Check if docker-compose is available +if ! command -v docker-compose > /dev/null 2>&1; then + echo "❌ docker-compose not found. Please install docker-compose." + exit 1 +fi + +echo "✅ Docker is running" +echo "✅ docker-compose is available" + +# Copy environment file +if [ ! -f .env ]; then + echo "📝 Creating .env file from template..." + cp .env.docker .env +fi + +# Build and start containers +echo "🔨 Building and starting containers..." +docker-compose down --remove-orphans +docker-compose build --no-cache +docker-compose up -d + +# Wait for services to start +echo "âŗ Waiting for services to start..." +sleep 10 + +# Check service health +echo "đŸĨ Checking service health..." + +# Check frontend +if curl -f http://localhost:8087/health > /dev/null 2>&1; then + echo "✅ Frontend is healthy (http://localhost:8087)" +else + echo "❌ Frontend health check failed" +fi + +# Check backend +if curl -f http://localhost:3009/health > /dev/null 2>&1; then + echo "✅ Backend is healthy (http://localhost:3009)" +else + echo "❌ Backend health check failed" +fi + +# Show container status +echo "" +echo "📊 Container Status:" +docker-compose ps + +echo "" +echo "🎉 Deployment complete!" +echo "📱 Frontend: http://localhost:8087" +echo "🔧 Backend API: http://localhost:3009" +echo "🔐 Default login: admin / admin" +echo "" +echo "📋 To view logs: docker-compose logs -f" +echo "🛑 To stop: docker-compose down" \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 1ffef600d..ca4fbc008 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,31 @@ { - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "types": ["node", "react", "react-dom"], + "typeRoots": ["./node_modules/@types"], + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "jsxImportSource": "react", + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "allowJs": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "server"] } diff --git a/vite.config.ts b/vite.config.ts index d85759a43..8e2cbaceb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ } }, server: { + port: 8080, proxy: { '/api': { target: process.env.BACKEND_URL || 'http://localhost:3001', From 4b7e9fd88e0424c5823ec8389ae77b4af5a5200d Mon Sep 17 00:00:00 2001 From: smashingtags <48292010+smashingtags@users.noreply.github.com> Date: Sun, 20 Jul 2025 11:46:02 -0400 Subject: [PATCH 2/5] Update docker-publish.yml added feature/overhaul branch to builds --- .github/workflows/docker-publish.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 640e63035..630da2679 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -7,12 +7,14 @@ on: branches: - "main" - "feature/standard-deployment" + - "feature/overhaul" # Trigger builds for semver tags and for a "dev" tag. tags: [ 'v*.*.*', 'dev' ] pull_request: branches: - "main" - "feature/standard-deployment" + - "feature/overhaul" env: # Use docker.io for Docker Hub if empty From de7451aa110d329686b1aa9ed174a8f73aa045fc Mon Sep 17 00:00:00 2001 From: smashingtags <48292010+smashingtags@users.noreply.github.com> Date: Sun, 20 Jul 2025 12:00:08 -0400 Subject: [PATCH 3/5] Delete .vs directory --- .vs/.test | 1 - ...caed7bf4-637a-4795-b2ae-3ccb22242b7e.vsidx | Bin 5834 -> 0 bytes .vs/1.29.25homelabarrv3/v17/.wsuo | Bin 14336 -> 0 bytes .../v17/DocumentLayout.json | 23 ------------------ .vs/ProjectSettings.json | 3 --- .vs/VSWorkspaceState.json | 6 ----- .vs/newtest.file | 1 - .vs/slnx.sqlite | Bin 102400 -> 0 bytes 8 files changed, 34 deletions(-) delete mode 100644 .vs/.test delete mode 100644 .vs/1.29.25homelabarrv3/FileContentIndex/caed7bf4-637a-4795-b2ae-3ccb22242b7e.vsidx delete mode 100644 .vs/1.29.25homelabarrv3/v17/.wsuo delete mode 100644 .vs/1.29.25homelabarrv3/v17/DocumentLayout.json delete mode 100644 .vs/ProjectSettings.json delete mode 100644 .vs/VSWorkspaceState.json delete mode 100644 .vs/newtest.file delete mode 100644 .vs/slnx.sqlite diff --git a/.vs/.test b/.vs/.test deleted file mode 100644 index 8b1378917..000000000 --- a/.vs/.test +++ /dev/null @@ -1 +0,0 @@ - diff --git a/.vs/1.29.25homelabarrv3/FileContentIndex/caed7bf4-637a-4795-b2ae-3ccb22242b7e.vsidx b/.vs/1.29.25homelabarrv3/FileContentIndex/caed7bf4-637a-4795-b2ae-3ccb22242b7e.vsidx deleted file mode 100644 index e9bae22a693c0a811e3e9f50f08a41af545d0e10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5834 zcmb`L&2rN)5XU>B$$x-q=cINl409AAkqoA$Uz# zIksaraRyn=p>mSRum9cuYV|Rme0}}$?Cj5*v*Hz(<1N1X@+lgNg!1(5jS>rpjgH<0 z=O2Uf51Gh;kr`2H^*#y`5kG*UDHjTMl!L#2%h&%Rfv*DCkX*7Z;+ZQlrRJnW)a;0y zg!kd}`^{B&eHY}(LCnPmj>ugqN3qDI&=72Mc8GOEP6~JCv^$gTf!D}jM$mhHBzQ-a zpc&;Rhy|b1G+5}vu$@(2hSQr)UrN5CO3D-11erNzOQqNmIsG;cuW!TZ*GET3Bu^>d z6sbH)D?W5YO0=1zfW`>k1$9LVZDOrYTJDJa5nfEL!qc8RA~mQLsAy;W@v?Mn8zs^J zb~j=`M`UR-;D$cz=X*buEu_#f;d`RD%9x2|GG|1=C`q`sWM7q#&$bPPwy=DW*yVw= znchf6=7hJPzDT=%N;{wS4c-??^PI+61L&X21u(Xq(;-YWUyEQP(oAtoduvu&ct`EV))c4Q5p)Nn{i zBq#>B9Mv<@*B;!uwoAx=LQ0_wp&W(`94j(a(1_c+h@=v0TqYVKy^%j>)EJ=@u9B5O zzV;}Rv^DzMqwKcW#2KkYdIyf%t)W%wEssrxr@AVW{(&1sVBXxm_#>Cn&>Th&%e-EA zeH~wsB{OTH5~vp-SEP!g3n>GmH(i@xXU7p)oc1`@wzl*!Ds2CU9x5BivEaL=VIP!W z+QJ7y-Wp;GUXPtyJMaleCbm~1A#5MKpUO}vp+1Wx$BDkJpnk|pVDgDZ_fl^MJ_VA1 znu(U{ek!LTy{E9oWuj52e#pBFa<~&|%3u%eflP=YQGHB7)RTGC1Gzhz;Ysods(RZ4 z$>=O1k{-${nLUws%FAu~DrL>FCvrs?#hg;X54k;%xR8D#zdD_z(P%Lm~hG diff --git a/.vs/1.29.25homelabarrv3/v17/.wsuo b/.vs/1.29.25homelabarrv3/v17/.wsuo deleted file mode 100644 index 2cca1c58fc9bfcd1ee2116312b69c3a522af02c1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14336 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 rt>)ZD+1scmzK}1__vj Date: Sun, 20 Jul 2025 12:31:37 -0400 Subject: [PATCH 4/5] Added users.json --- server/config/users.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 server/config/users.json diff --git a/server/config/users.json b/server/config/users.json new file mode 100644 index 000000000..a26e7360d --- /dev/null +++ b/server/config/users.json @@ -0,0 +1,11 @@ +[ + { + "id": "user_e1b8wsmtnmdbw9dmk", + "username": "admin", + "email": "admin@homelabarr.local", + "role": "admin", + "password": "$2b$12$/B90y/.oVSzjvdLYt2CkVeSf16IWc4QIjBgEbOShtxyPbydRM3Rba", + "createdAt": "2025-07-20T16:31:33.692Z", + "lastLogin": null + } +] \ No newline at end of file From 56470fb5968338c2cb9c60dd2e2ed118fc90f375 Mon Sep 17 00:00:00 2001 From: smashingtags <48292010+smashingtags@users.noreply.github.com> Date: Sun, 20 Jul 2025 12:34:39 -0400 Subject: [PATCH 5/5] Fix Docker Container Deployment Issues --- server/index.js | 37 ++++++++++++++++++++++++++------ server/templates/homepage.yml | 12 ++++++++--- server/templates/jellyfin.yml | 9 +++++--- server/templates/plex.yml | 10 ++++++--- server/templates/portainer.yml | 10 ++++++++- server/templates/qbittorrent.yml | 10 ++++----- server/templates/traefik.yml | 11 ++++++---- 7 files changed, 74 insertions(+), 25 deletions(-) diff --git a/server/index.js b/server/index.js index 3eb8bbc4f..5e9f64238 100644 --- a/server/index.js +++ b/server/index.js @@ -963,22 +963,30 @@ app.post('/deploy', authEnabled ? requireAuth : optionalAuth, async (req, res) = // Process volumes with proper path handling const processedVolumes = (serviceConfig.volumes || []).map(volume => { + // Skip Docker socket and system mounts + if (volume.includes('/var/run/docker.sock') || volume.includes('/proc') || volume.includes('/sys')) { + return volume; + } + const [host, container, options] = volume.split(':'); // Handle relative paths and special cases let hostPath; if (host.startsWith('./')) { // Create app-specific config directory - hostPath = path.join(process.cwd(), 'data', appId, host.substring(2)); + hostPath = path.join(process.cwd(), 'server', 'data', appId, host.substring(2)); } else if (host.startsWith('/')) { - // Absolute path - use as is + // Absolute path - use as is (but validate it's safe) + if (host.startsWith('/var/run') || host.startsWith('/proc') || host.startsWith('/sys')) { + return volume; // System paths, don't modify + } hostPath = host; } else { // Relative path - create in app data directory - hostPath = path.join(process.cwd(), 'data', appId, host); + hostPath = path.join(process.cwd(), 'server', 'data', appId, host); } - // Ensure directory exists + // Ensure directory exists for non-system paths try { if (!fs.existsSync(hostPath)) { fs.mkdirSync(hostPath, { recursive: true }); @@ -986,7 +994,8 @@ app.post('/deploy', authEnabled ? requireAuth : optionalAuth, async (req, res) = } } catch (error) { console.error(`Error creating volume path ${hostPath}:`, error); - throw new Error(`Failed to create volume path: ${error.message}`); + // Don't fail deployment for volume creation issues + console.warn(`Warning: Could not create volume path ${hostPath}, using default`); } return options ? `${hostPath}:${container}:${options}` : `${hostPath}:${container}`; @@ -1003,7 +1012,7 @@ app.post('/deploy', authEnabled ? requireAuth : optionalAuth, async (req, res) = }, Binds: processedVolumes, PortBindings: {}, - NetworkMode: 'proxy', // Use proxy network to match templates + NetworkMode: 'homelabarr', // Use homelabarr network by default }, ExposedPorts: {} }; @@ -1051,6 +1060,22 @@ app.post('/deploy', authEnabled ? requireAuth : optionalAuth, async (req, res) = throw new Error(`Failed to create container: ${error.message}`); } + // Connect to networks after creation + try { + if (finalConfig.networks && finalConfig.networks.proxy) { + const proxyNetwork = docker.getNetwork('proxy'); + await proxyNetwork.connect({ Container: container.id }); + console.log('Connected to proxy network'); + } + + const homelabarrNetwork = docker.getNetwork('homelabarr'); + await homelabarrNetwork.connect({ Container: container.id }); + console.log('Connected to homelabarr network'); + } catch (networkError) { + console.warn('Network connection warning:', networkError.message); + // Don't fail deployment for network issues + } + try { await container.start(); console.log('Container started'); diff --git a/server/templates/homepage.yml b/server/templates/homepage.yml index 9c7488eea..27073a699 100644 --- a/server/templates/homepage.yml +++ b/server/templates/homepage.yml @@ -1,14 +1,20 @@ version: '3' services: homepage: - image: ghcr.io/benphelps/homepage:latest + image: ghcr.io/gethomepage/homepage:latest container_name: homepage restart: unless-stopped - networks: - - proxy + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + ports: + - "3000:3000" volumes: - ./config:/app/config - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - proxy networks: proxy: diff --git a/server/templates/jellyfin.yml b/server/templates/jellyfin.yml index 95b111fd9..fba3f0d54 100644 --- a/server/templates/jellyfin.yml +++ b/server/templates/jellyfin.yml @@ -4,17 +4,20 @@ services: image: jellyfin/jellyfin:latest container_name: jellyfin restart: unless-stopped - networks: - - proxy environment: + - PUID=1000 + - PGID=1000 - TZ=UTC ports: - "8096:8096" - "8920:8920" - "1900:1900/udp" volumes: - - ${media_path}:/media - ./config:/config + - ./cache:/cache + - ${media_path:-./media}:/media:ro + networks: + - proxy networks: proxy: diff --git a/server/templates/plex.yml b/server/templates/plex.yml index 4bc89c850..f337020c0 100644 --- a/server/templates/plex.yml +++ b/server/templates/plex.yml @@ -4,11 +4,12 @@ services: image: plexinc/pms-docker:latest container_name: plex restart: unless-stopped - networks: - - proxy environment: - PLEX_CLAIM=${claim_token} + - PUID=1000 + - PGID=1000 - TZ=UTC + - VERSION=docker ports: - "32400:32400" - "1900:1900/udp" @@ -17,8 +18,11 @@ services: - "32413:32413/udp" - "32414:32414/udp" volumes: - - ${media_path}:/data - ./config:/config + - ./transcode:/transcode + - ${media_path:-./media}:/data:ro + networks: + - proxy networks: proxy: diff --git a/server/templates/portainer.yml b/server/templates/portainer.yml index 595571c49..ef8fcee11 100644 --- a/server/templates/portainer.yml +++ b/server/templates/portainer.yml @@ -6,9 +6,17 @@ services: restart: unless-stopped security_opt: - no-new-privileges:true + environment: + - TZ=UTC volumes: - /etc/localtime:/etc/localtime:ro - /var/run/docker.sock:/var/run/docker.sock:ro - ./data:/data ports: - - "${port}:9000" \ No newline at end of file + - "${port:-9000}:9000" + networks: + - proxy + +networks: + proxy: + external: true \ No newline at end of file diff --git a/server/templates/qbittorrent.yml b/server/templates/qbittorrent.yml index fc729ec88..b5b094aec 100644 --- a/server/templates/qbittorrent.yml +++ b/server/templates/qbittorrent.yml @@ -1,23 +1,23 @@ version: '3' services: qbittorrent: - image: linuxserver/qbittorrent:latest + image: lscr.io/linuxserver/qbittorrent:latest container_name: qbittorrent restart: unless-stopped - networks: - - proxy environment: - PUID=1000 - PGID=1000 - TZ=UTC - WEBUI_PORT=8080 ports: - - "8080:8080" + - "${webui_port:-8080}:8080" - "6881:6881" - "6881:6881/udp" volumes: - ./config:/config - - ${downloads_path}:/downloads + - ${downloads_path:-./downloads}:/downloads + networks: + - proxy networks: proxy: diff --git a/server/templates/traefik.yml b/server/templates/traefik.yml index 5fbdb9d34..29cf46a2a 100644 --- a/server/templates/traefik.yml +++ b/server/templates/traefik.yml @@ -1,22 +1,23 @@ version: '3' services: traefik: - image: traefik:latest + image: traefik:v3.0 container_name: traefik restart: unless-stopped security_opt: - no-new-privileges:true - networks: - - proxy + environment: + - TZ=UTC ports: - - "8080:8080" - "80:80" - "443:443" + - "${dashboard_port:-8080}:8080" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./config:/etc/traefik - ./certificates:/certificates command: + - "--api.dashboard=true" - "--api.insecure=true" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" @@ -28,6 +29,8 @@ services: - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" - "--certificatesresolvers.letsencrypt.acme.email=${email}" - "--certificatesresolvers.letsencrypt.acme.storage=/certificates/acme.json" + networks: + - proxy networks: proxy: