diff --git a/.gitignore b/.gitignore index 9e91245..82687ac 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ /prisma/db.sqlite /prisma/db.sqlite-journal db.sqlite +data/settings.db # next.js /.next/ diff --git a/README.md b/README.md index c04633a..a437a25 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ A modern web-based management interface for Proxmox VE (PVE) helper scripts. This tool provides a user-friendly way to discover, download, and execute community-sourced Proxmox scripts locally with real-time terminal output streaming. +## 🎯 Deployment Options + +This application can be deployed in multiple ways to suit different environments: + +- **🐧 Proxmox Host**: Run directly on your Proxmox VE host system +- **📦 Debian LXC Container**: Deploy inside a Debian LXC container for better isolation +- **⚡ Quick Install**: Use the automated `install.sh` script for easy setup + +All deployment methods provide the same functionality and web interface. + ## 🌟 Features - **Web-based Interface**: Modern React/Next.js frontend with real-time terminal emulation @@ -33,108 +43,278 @@ A modern web-based management interface for Proxmox VE (PVE) helper scripts. Thi - **Container Scripts**: Pre-configured LXC container setups - **Installation Scripts**: System setup and configuration tools +### Database +- **SQLite Database**: Local database stored at `data/settings.db` +- **Server Management**: Stores Proxmox server configurations and credentials +- **Automatic Setup**: Database and tables are created automatically on first run +- **Data Persistence**: Settings persist across application restarts + ## 📋 Prerequisites +### For All Deployment Methods - **Node.js** 22+ and npm - **Git** for cloning the repository -- **Proxmox VE environment** -- **build-essentials** ```apt install build-essential``` +- **Proxmox VE environment** (host or access to Proxmox cluster) +- **SQLite** (included with Node.js better-sqlite3 package) + +### For Proxmox Host Installation +- **build-essentials**: `apt install build-essential` +- Direct access to Proxmox host system + +### For Debian LXC Container Installation +- **Debian LXC container** (Debian 11+ recommended) +- **build-essentials**: `apt install build-essential` +- Container with sufficient resources (2GB RAM, 4GB storage minimum) +- Network access from container to Proxmox host +- Optional: Privileged container for full Proxmox integration + +### For Quick Install (install.sh) +- **Proxmox VE host** (script automatically detects and configures) +- Internet connectivity for downloading dependencies ## 🚀 Installation -You can either install automatically via the provided installer script or do a manual setup. +Choose the installation method that best fits your environment: -### Option 1: Install via Bash (Recommended) +### Option 1: Quick Install with install.sh (Recommended for Proxmox Host) -Run this command directly on your Proxmox VE host: +Run this command directly on your Proxmox VE host or on any Debian based lxc: ```bash bash -c "$(curl -fsSL https://raw.githubusercontent.com/michelroegl-brunner/PVESciptslocal/main/install.sh)" ``` -## The script will: -- Verify that you are running on Proxmox VE -- Check and install git and Node.js 24.x if missing -- Clone the repository into /opt/PVESciptslocal (or your chosen path) -- Run npm install and build the project -- Set up .env from .env.example if missing -- Create a systemd service (pvescriptslocal.service) for easy start/stop management +**What the script does:** +- ✅ Installs required dependencies (build-essential, git, Node.js 24.x) +- ✅ Clones the repository into `/opt/PVESciptslocal` (or your chosen path) +- ✅ Runs npm install and builds the project +- ✅ Sets up `.env` from `.env.example` if missing +- ✅ Creates database directory (`data/`) for SQLite storage +- ✅ Creates a systemd service (`pvescriptslocal.service`) for easy management -After installation, the app will be accessible at: -👉 http://:3000 +**After installation:** +- 🌐 Access the app at: `http://:3000` +- 🔧 Manage the service with: + ```bash + systemctl start pvescriptslocal + systemctl stop pvescriptslocal + systemctl status pvescriptslocal + ``` + +### Option 2: Debian LXC Container Installation + +For better isolation and security, you can run PVE Scripts Local inside a Debian LXC container: + +#### Step 1: Create Debian LXC Container -You can manage the service with: ```bash -systemctl start pvescriptslocal -systemctl stop pvescriptslocal -systemctl status pvescriptslocal +bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/debian.sh)" +``` +Then run the installer: + +```bash +bash -c "$(curl -fsSL https://raw.githubusercontent.com/michelroegl-brunner/PVESciptslocal/main/install.sh)" ``` +#### Step 2: Install Dependencies in Container when installer is not used +```bash +# Enter the container +pct enter 100 -### Option 2: Manual Installation +# Update and install dependencies +apt update && apt install -y build-essential git curl -### 1. Clone the Repository +# Install Node.js 24.x +curl -fsSL https://deb.nodesource.com/setup_24.x | bash - +apt install -y nodejs +``` +#### Step 3: Clone and Setup Application +```bash +# Clone the repository +git clone https://github.com/michelroegl-brunner/PVESciptslocal.git /opt/PVESciptslocal +cd /opt/PVESciptslocal + +# Install dependencies and build +npm install +npm run build + +# Setup environment +cp .env.example .env + +# Create database directory +mkdir -p data +chmod 755 data +``` + +#### Step 4: Start the Application +```bash +# Start in production mode +npm start + +# Or create a systemd service (optional) +# Follow the same systemd setup as the install.sh script +``` + +**Access the application:** +- 🌐 Container IP: `http://:3000` +- 🔧 Container management: `pct start 100`, `pct stop 100`, `pct status 100` + +### Option 3: Manual Installation (Proxmox Host) + +#### Step 1: Clone the Repository ```bash git clone https://github.com/michelroegl-brunner/PVESciptslocal.git cd PVESciptslocal ``` -### 2. Install Dependencies - +#### Step 2: Install Dependencies ```bash npm install ``` -### 3. Environment Configuration - -Copy the example environment file and configure your settings: - +#### Step 3: Environment Configuration ```bash cp .env.example .env +# Edit .env file with your specific settings if needed ``` -### 4. Start the Application - -#### Production Mode +#### Step 4: Database Setup ```bash +# Create database directory +mkdir -p data +chmod 755 data +``` + +#### Step 5: Build and Start +```bash +# Production mode npm run build npm start + +# Development mode +npm run dev:server ``` -The application will be available at `http://IP:3000` +**Access the application:** +- 🌐 Available at: `http://:3000` + +## 📝 LXC Container Specific Notes + +### Container Requirements +- **OS**: Debian 11+ (Debian 12 recommended) +- **Resources**: Minimum 2GB RAM, 4GB storage +- **Network**: Bridge connection to Proxmox network +- **Privileges**: Unprivileged containers work, but privileged containers provide better Proxmox integration + +### Container Configuration Tips +- **Privileged Container**: Use `--unprivileged 0` for full Proxmox API access +- **Resource Allocation**: Allocate at least 2 CPU cores and 2GB RAM for optimal performance +- **Storage**: Use at least 8GB for the container to accommodate Node.js and dependencies +- **Network**: Ensure the container can reach the Proxmox host API + +### Security Considerations +- **Unprivileged Containers**: More secure but may have limited Proxmox functionality +- **Privileged Containers**: Full Proxmox access but less secure isolation +- **Network Access**: Ensure proper firewall rules for the container + +### Troubleshooting LXC Installation +- **Permission Issues**: Ensure the container has proper permissions for Proxmox API access +- **Network Connectivity**: Verify the container can reach the Proxmox host +- **Resource Limits**: Check if the container has sufficient resources allocated ## 🎯 Usage ### 1. Access the Web Interface -Open your browser and navigate to `http://IP:3000` (or your configured host/port). +The web interface is accessible regardless of your deployment method: -### 2. Browse Available Scripts +- **Proxmox Host Installation**: `http://:3000` +- **LXC Container Installation**: `http://:3000` +- **Custom Installation**: `http://:3000` + +### 2. Service Management + +#### For install.sh installations (systemd service): +```bash +# Start the service +systemctl start pvescriptslocal + +# Stop the service +systemctl stop pvescriptslocal + +# Check service status +systemctl status pvescriptslocal + +# Enable auto-start on boot +systemctl enable pvescriptslocal + +# View service logs +journalctl -u pvescriptslocal -f +``` + +#### For LXC container installations: +```bash +# Container management +pct start # Start container +pct stop # Stop container +pct status # Check container status + +# Access container shell +pct enter + +# Inside container - start application +cd /opt/PVESciptslocal +npm start +``` + +#### For manual installations: +```bash +# Start application +npm start + +# Development mode +npm run dev:server + +# Build for production +npm run build +``` + +### 3. Browse Available Scripts - The main page displays a grid of available Proxmox scripts - Use the search functionality to find specific scripts - Scripts are categorized by type (containers, installations, etc.) -### 3. Download Scripts +### 4. Download Scripts - Click on any script card to view details - Use the "Download" button to fetch scripts from GitHub - Downloaded scripts are stored locally in the `scripts/` directory -### 4. Execute Scripts +### 5. Execute Scripts - Click "Run Script" on any downloaded script - A terminal window will open with real-time output - Interact with the script through the web terminal - Use the close button to stop execution -### 5. Script Management +### 6. Script Management - View script execution history - Update scripts to latest versions - Manage local script collections +### 7. Database Management + +The application uses SQLite for storing server configurations: + +- **Database Location**: `data/settings.db` +- **Automatic Creation**: Database and tables are created on first run +- **Server Storage**: Proxmox server credentials and configurations +- **Backup**: Copy `data/settings.db` to backup your server configurations +- **Reset**: Delete `data/settings.db` to reset all server configurations + ## 📁 Project Structure ``` @@ -151,7 +331,10 @@ PVESciptslocal/ │ │ ├── _components/ # React components │ │ └── page.tsx # Main page │ └── server/ # Server-side code +│ ├── database.js # SQLite database service │ └── services/ # Business logic services +├── data/ # Database storage +│ └── settings.db # SQLite database file ├── public/ # Static assets ├── server.js # Main server file └── package.json # Dependencies and scripts @@ -201,6 +384,18 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file - Server logs: Check console output or `server.log` - Script execution: View in web terminal +## 🎯 Quick Start Summary + +Choose your preferred deployment method: + +| Method | Best For | Command | +|--------|----------|---------| +| **Quick Install** | Proxmox hosts or Debian LXC, easy setup | `bash -c "$(curl -fsSL https://raw.githubusercontent.com/michelroegl-brunner/PVESciptslocal/main/install.sh)"` | +| **LXC Container** | Better isolation, security | Create Debian LXC → Install dependencies → Clone repo → `npm start` | +| **Manual Install** | Custom setups, development | `git clone` → `npm install` → `npm run build` → `npm start` | + +All methods provide the same web interface at `http://:3000` with full Proxmox script management capabilities. + --- **Note**: This is alpha software. Use with caution in production environments and always backup your Proxmox configuration before running scripts. diff --git a/install.sh b/install.sh index 63676df..234388b 100644 --- a/install.sh +++ b/install.sh @@ -15,20 +15,13 @@ msg_info() { echo -e "⏳ $YW$1$CL"; } msg_ok() { echo -e "✔️ $GN$1$CL"; } msg_err() { echo -e "❌ $RD$1$CL"; } -# --- PVE Check ---------------------------------------------------------------- -check_pve() { - if ! command -v pveversion >/dev/null 2>&1; then - msg_err "This script must be executed on a Proxmox VE host." - exit 1 - fi - msg_ok "Proxmox VE detected: $(pveversion)" -} + # --- Dependency Check & Install ----------------------------------------------- check_dependencies() { msg_info "Checking required packages (build-essential, git)..." apt-get update - apt-get install -y build-essential git + apt-get install -y build-essential git sshpass expect msg_ok "Dependencies installed." } @@ -75,6 +68,11 @@ setup_app() { msg_ok ".env file already exists, keeping it." fi + msg_info "Setting up database directory..." + mkdir -p data + chmod 755 data + msg_ok "Database directory created." + msg_info "Building application..." npm run build msg_ok "Build completed." diff --git a/package-lock.json b/package-lock.json index c2dd298..bd58e26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@xterm/addon-fit": "^0.10.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/xterm": "^5.5.0", + "better-sqlite3": "^9.6.0", "next": "^15.5.3", "node-pty": "^1.0.0", "react": "^19.0.0", @@ -38,6 +39,7 @@ "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/better-sqlite3": "^7.6.8", "@types/node": "^24.3.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -2819,6 +2821,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", @@ -4055,6 +4067,57 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -4112,6 +4175,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4517,6 +4604,21 @@ "dev": true, "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -4527,6 +4629,15 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4584,7 +4695,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -4647,6 +4757,15 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -5349,6 +5468,15 @@ "node": ">=0.10.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", @@ -5453,6 +5581,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5545,6 +5679,12 @@ "node": ">=0.4.x" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -5681,6 +5821,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -5995,6 +6141,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -6042,6 +6208,18 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -7232,6 +7410,18 @@ "node": ">=8.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -7259,7 +7449,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7304,6 +7493,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -7344,6 +7539,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/napi-postinstall": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", @@ -7447,6 +7648,30 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-abi": { + "version": "3.77.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.77.0.tgz", + "integrity": "sha512-DSmt0OEcLoK4i3NuscSbGjOf3bqiDEutejqENSplMSFA/gmB8mkED9G4pKWnPl7MDU4rSHebKPHeitpDfyH0cQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-pty": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz", @@ -7594,6 +7819,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7839,6 +8073,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8023,6 +8283,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8054,6 +8324,30 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.1.1", "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", @@ -8110,6 +8404,20 @@ "react": ">= 0.14.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -8336,6 +8644,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -8637,6 +8965,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-git": { "version": "3.28.0", "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz", @@ -8731,6 +9104,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -9102,6 +9484,40 @@ "node": ">=18" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -9359,6 +9775,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -9588,6 +10016,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vite": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", @@ -10080,6 +10514,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", diff --git a/package.json b/package.json index 6a5837d..b22a14b 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "superjson": "^2.2.1", "ws": "^8.18.3", "xterm": "^5.3.0", - "zod": "^3.24.2" + "zod": "^3.24.2", + "better-sqlite3": "^9.6.0" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", @@ -67,7 +68,8 @@ "tailwindcss": "^4.0.15", "typescript": "^5.8.2", "typescript-eslint": "^8.27.0", - "vitest": "^3.2.4" + "vitest": "^3.2.4", + "@types/better-sqlite3": "^7.6.8" }, "ct3aMetadata": { "initVersion": "7.39.3" diff --git a/scripts/core/build.func b/scripts/core/build.func index 7fb98b4..95a78d7 100755 --- a/scripts/core/build.func +++ b/scripts/core/build.func @@ -15,7 +15,9 @@ variables() { CT_TYPE=${var_unprivileged:-$CT_TYPE} } -source "$(dirname "${BASH_SOURCE[0]}")/core.func" +# Get absolute path to core directory +CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$CORE_DIR/core.func" # This function enables error handling in the script by setting options and defining a trap for the ERR signal. @@ -972,7 +974,7 @@ install_script() { header_info echo -e "${INFO}${HOLD} ${GN}Using Config File on node $PVEHOST_NAME${CL}" METHOD="config_file" - source "$(dirname "${BASH_SOURCE[0]}")/config-file.func" + source "$CORE_DIR/config-file.func" config_file break ;; @@ -1043,7 +1045,7 @@ check_container_storage() { } start() { - source "$(dirname "${BASH_SOURCE[0]}")/tools.func" + source "$CORE_DIR/tools.func" if command -v pveversion >/dev/null 2>&1; then install_script @@ -1105,10 +1107,12 @@ build_container() { TEMP_DIR=$(mktemp -d) pushd "$TEMP_DIR" >/dev/null + # CORE_DIR is already defined at the top of the file + if [ "$var_os" == "alpine" ]; then - export FUNCTIONS_FILE_PATH="$(cat "$(dirname "${BASH_SOURCE[0]}")/core.func" && echo && cat "$(dirname "${BASH_SOURCE[0]}")/tools.func" && echo && cat "$(dirname "${BASH_SOURCE[0]}")/alpine-install.func")" + export FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/alpine-install.func")" else - export FUNCTIONS_FILE_PATH="$(cat "$(dirname "${BASH_SOURCE[0]}")/core.func" && echo && cat "$(dirname "${BASH_SOURCE[0]}")/tools.func" && echo && cat "$(dirname "${BASH_SOURCE[0]}")/install.func")" + export FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/install.func")" fi export DIAGNOSTICS="$DIAGNOSTICS" @@ -1143,7 +1147,7 @@ build_container() { $PW " # This executes create_lxc.sh and creates the container and .conf file - bash "$(dirname "${BASH_SOURCE[0]}")/create_lxc.sh" $? + bash "$CORE_DIR/create_lxc.sh" $? LXC_CONFIG="/etc/pve/lxc/${CTID}.conf" @@ -1337,7 +1341,7 @@ EOF' msg_ok "Customized LXC Container" - lxc-attach -n "$CTID" -- bash -c "$(cat "$(dirname "${BASH_SOURCE[0]}")/../install/${var_install}.sh")" + lxc-attach -n "$CTID" -- bash -c "$(cat "$(dirname "$CORE_DIR")/install/${var_install}.sh")" } # This function sets the description of the container. diff --git a/server.js b/server.js index 9d206ac..e49879b 100644 --- a/server.js +++ b/server.js @@ -6,6 +6,7 @@ import { spawn } from 'child_process'; import { join, resolve } from 'path'; import stripAnsi from 'strip-ansi'; import { spawn as ptySpawn } from 'node-pty'; +import { getSSHExecutionService } from './src/server/ssh-execution-service.js'; const dev = process.env.NODE_ENV !== 'production'; const hostname = '0.0.0.0'; @@ -25,12 +26,28 @@ const handle = app.getRequestHandler(); * @property {ExtendedWebSocket} ws */ +/** + * @typedef {Object} ServerInfo + * @property {string} name + * @property {string} ip + * @property {string} user + * @property {string} password + */ + +/** + * @typedef {Object} ExecutionResult + * @property {any} process + * @property {Function} kill + */ + /** * @typedef {Object} WebSocketMessage * @property {string} action * @property {string} [scriptPath] * @property {string} [executionId] * @property {string} [input] + * @property {string} [mode] + * @property {ServerInfo} [server] */ class ScriptExecutionHandler { @@ -55,7 +72,10 @@ class ScriptExecutionHandler { ws.on('message', (data) => { try { - const message = JSON.parse(data.toString()); + const rawMessage = data.toString(); + console.log('Raw WebSocket message received:', rawMessage); + const message = JSON.parse(rawMessage); + console.log('Parsed WebSocket message:', message); this.handleMessage(/** @type {ExtendedWebSocket} */ (ws), message); } catch (error) { console.error('Error parsing WebSocket message:', error); @@ -83,12 +103,16 @@ class ScriptExecutionHandler { * @param {WebSocketMessage} message */ async handleMessage(ws, message) { - const { action, scriptPath, executionId, input } = message; + const { action, scriptPath, executionId, input, mode, server } = message; + + // Debug logging + console.log('WebSocket message received:', { action, scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null }); + console.log('Full message object:', JSON.stringify(message, null, 2)); switch (action) { case 'start': if (scriptPath && executionId) { - await this.startScriptExecution(ws, scriptPath, executionId); + await this.startScriptExecution(ws, scriptPath, executionId, mode, server); } else { this.sendMessage(ws, { type: 'error', @@ -123,10 +147,39 @@ class ScriptExecutionHandler { * @param {ExtendedWebSocket} ws * @param {string} scriptPath * @param {string} executionId + * @param {string} mode + * @param {ServerInfo|null} server */ - async startScriptExecution(ws, scriptPath, executionId) { + async startScriptExecution(ws, scriptPath, executionId, mode = 'local', server = null) { try { - // Basic validation + // Debug logging + console.log('startScriptExecution called with:', { mode, server: server ? { name: server.name, ip: server.ip } : null }); + console.log('Full server object:', JSON.stringify(server, null, 2)); + + // Check if execution is already running + if (this.activeExecutions.has(executionId)) { + this.sendMessage(ws, { + type: 'error', + data: 'Script execution already running', + timestamp: Date.now() + }); + return; + } + + // Handle SSH execution + if (mode === 'ssh' && server) { + console.log('Starting SSH execution...'); + await this.startSSHScriptExecution(ws, scriptPath, executionId, server); + return; + } + + if (mode === 'ssh' && !server) { + console.log('SSH mode requested but no server provided, falling back to local execution'); + } + + console.log('Starting local execution...'); + + // Basic validation for local execution const scriptsDir = join(process.cwd(), 'scripts'); const resolvedPath = resolve(scriptPath); @@ -139,16 +192,6 @@ class ScriptExecutionHandler { return; } - // Check if execution is already running - if (this.activeExecutions.has(executionId)) { - this.sendMessage(ws, { - type: 'error', - data: 'Script execution already running', - timestamp: Date.now() - }); - return; - } - // Start script execution with pty for proper TTY support const childProcess = ptySpawn('bash', [resolvedPath], { cwd: scriptsDir, @@ -206,6 +249,69 @@ class ScriptExecutionHandler { } } + /** + * Start SSH script execution + * @param {ExtendedWebSocket} ws + * @param {string} scriptPath + * @param {string} executionId + * @param {ServerInfo} server + */ + async startSSHScriptExecution(ws, scriptPath, executionId, server) { + console.log('startSSHScriptExecution called with server:', server); + const sshService = getSSHExecutionService(); + + // Send start message + this.sendMessage(ws, { + type: 'start', + data: `Starting SSH execution of ${scriptPath} on ${server.name} (${server.ip})`, + timestamp: Date.now() + }); + + try { + const execution = /** @type {ExecutionResult} */ (await sshService.executeScript( + server, + scriptPath, + /** @param {string} data */ (data) => { + // Handle data output + this.sendMessage(ws, { + type: 'output', + data: data, + timestamp: Date.now() + }); + }, + /** @param {string} error */ (error) => { + // Handle errors + this.sendMessage(ws, { + type: 'error', + data: error, + timestamp: Date.now() + }); + }, + /** @param {number} code */ (code) => { + // Handle process exit + this.sendMessage(ws, { + type: 'end', + data: `SSH script execution finished with code: ${code}`, + timestamp: Date.now() + }); + + // Clean up + this.activeExecutions.delete(executionId); + } + )); + + // Store the execution + this.activeExecutions.set(executionId, { process: execution.process, ws }); + + } catch (error) { + this.sendMessage(ws, { + type: 'error', + data: `Failed to start SSH execution: ${error instanceof Error ? error.message : String(error)}`, + timestamp: Date.now() + }); + } + } + /** * @param {string} executionId */ diff --git a/src/app/_components/ExecutionModeModal.tsx b/src/app/_components/ExecutionModeModal.tsx new file mode 100644 index 0000000..6705ec5 --- /dev/null +++ b/src/app/_components/ExecutionModeModal.tsx @@ -0,0 +1,246 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import type { Server } from '../../types/server'; + +interface ExecutionModeModalProps { + isOpen: boolean; + onClose: () => void; + onExecute: (mode: 'local' | 'ssh', server?: Server) => void; + scriptName: string; +} + +export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: ExecutionModeModalProps) { + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedMode, setSelectedMode] = useState<'local' | 'ssh'>('local'); + const [selectedServer, setSelectedServer] = useState(null); + + useEffect(() => { + if (isOpen) { + void fetchServers(); + } + }, [isOpen]); + + const fetchServers = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch('/api/servers'); + if (!response.ok) { + throw new Error('Failed to fetch servers'); + } + const data = await response.json(); + console.log('Fetched servers:', data); + setServers(data as Server[]); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setLoading(false); + } + }; + + const handleExecute = () => { + if (selectedMode === 'ssh' && !selectedServer) { + setError('Please select a server for SSH execution'); + return; + } + + console.log('ExecutionModeModal executing with:', { mode: selectedMode, server: selectedServer }); + onExecute(selectedMode, selectedServer ?? undefined); + onClose(); + }; + + const handleModeChange = (mode: 'local' | 'ssh') => { + setSelectedMode(mode); + if (mode === 'local') { + setSelectedServer(null); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+

Execution Mode

+ +
+ + {/* Content */} +
+
+

+ How would you like to execute "{scriptName}"? +

+

+ Choose between local execution or running the script on a remote server via SSH. +

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

{error}

+
+
+
+ )} + + {/* Execution Mode Selection */} +
+ {/* Local Execution */} +
handleModeChange('local')} + > +
+ handleModeChange('local')} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" + /> + +
+
+ + {/* SSH Execution */} +
handleModeChange('ssh')} + > +
+ handleModeChange('ssh')} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" + /> + +
+
+
+ + {/* Server Selection (only for SSH mode) */} + {selectedMode === 'ssh' && ( +
+ + {loading ? ( +
+
+

Loading servers...

+
+ ) : servers.length === 0 ? ( +
+

No servers configured

+

Add servers in Settings to use SSH execution

+
+ ) : ( + + )} +
+ )} + + {/* Action Buttons */} +
+ + +
+
+
+
+ ); +} diff --git a/src/app/_components/ScriptDetailModal.tsx b/src/app/_components/ScriptDetailModal.tsx index e739efd..07fae89 100644 --- a/src/app/_components/ScriptDetailModal.tsx +++ b/src/app/_components/ScriptDetailModal.tsx @@ -6,12 +6,13 @@ import { api } from '~/trpc/react'; import type { Script } from '~/types/script'; import { DiffViewer } from './DiffViewer'; import { TextViewer } from './TextViewer'; +import { ExecutionModeModal } from './ExecutionModeModal'; interface ScriptDetailModalProps { script: Script | null; isOpen: boolean; onClose: () => void; - onInstallScript?: (scriptPath: string, scriptName: string) => void; + onInstallScript?: (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => void; } export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }: ScriptDetailModalProps) { @@ -21,6 +22,7 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }: const [diffViewerOpen, setDiffViewerOpen] = useState(false); const [selectedDiffFile, setSelectedDiffFile] = useState(null); const [textViewerOpen, setTextViewerOpen] = useState(false); + const [executionModeOpen, setExecutionModeOpen] = useState(false); // Check if script files exist locally const { data: scriptFilesData, refetch: refetchScriptFiles, isLoading: scriptFilesLoading } = api.scripts.checkScriptFiles.useQuery( @@ -79,6 +81,11 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }: }; const handleInstallScript = () => { + if (!script) return; + setExecutionModeOpen(true); + }; + + const handleExecuteScript = (mode: 'local' | 'ssh', server?: any) => { if (!script || !onInstallScript) return; // Find the script path (CT or tools) @@ -86,7 +93,9 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }: if (scriptMethod?.script) { const scriptPath = `scripts/${scriptMethod.script}`; const scriptName = script.name; - onInstallScript(scriptPath, scriptName); + + // Pass execution mode and server info to the parent + onInstallScript(scriptPath, scriptName, mode, server); // Scroll to top of the page to see the terminal window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -513,6 +522,16 @@ export function ScriptDetailModal({ script, isOpen, onClose, onInstallScript }: onClose={() => setTextViewerOpen(false)} /> )} + + {/* Execution Mode Modal */} + {script && ( + setExecutionModeOpen(false)} + onExecute={handleExecuteScript} + /> + )} ); } diff --git a/src/app/_components/ServerForm.tsx b/src/app/_components/ServerForm.tsx new file mode 100644 index 0000000..9a35a95 --- /dev/null +++ b/src/app/_components/ServerForm.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useState } from 'react'; +import type { CreateServerData } from '../../types/server'; + +interface ServerFormProps { + onSubmit: (data: CreateServerData) => void; + initialData?: CreateServerData; + isEditing?: boolean; + onCancel?: () => void; +} + +export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel }: ServerFormProps) { + const [formData, setFormData] = useState( + initialData ?? { + name: '', + ip: '', + user: '', + password: '', + } + ); + + const [errors, setErrors] = useState>({}); + + const validateForm = (): boolean => { + const newErrors: Partial = {}; + + if (!formData.name.trim()) { + newErrors.name = 'Server name is required'; + } + + if (!formData.ip.trim()) { + newErrors.ip = 'IP address is required'; + } else { + // Basic IP validation + const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + if (!ipRegex.test(formData.ip)) { + newErrors.ip = 'Please enter a valid IP address'; + } + } + + if (!formData.user.trim()) { + newErrors.user = 'Username is required'; + } + + if (!formData.password.trim()) { + newErrors.password = 'Password is required'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (validateForm()) { + onSubmit(formData); + if (!isEditing) { + setFormData({ name: '', ip: '', user: '', password: '' }); + } + } + }; + + const handleChange = (field: keyof CreateServerData) => ( + e: React.ChangeEvent + ) => { + setFormData(prev => ({ ...prev, [field]: e.target.value })); + // Clear error when user starts typing + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: undefined })); + } + }; + + return ( +
+
+
+ + + {errors.name &&

{errors.name}

} +
+ +
+ + + {errors.ip &&

{errors.ip}

} +
+ +
+ + + {errors.user &&

{errors.user}

} +
+ +
+ + + {errors.password &&

{errors.password}

} +
+
+ +
+ {isEditing && onCancel && ( + + )} + +
+
+ ); +} + diff --git a/src/app/_components/ServerList.tsx b/src/app/_components/ServerList.tsx new file mode 100644 index 0000000..0ef9aa7 --- /dev/null +++ b/src/app/_components/ServerList.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { useState } from 'react'; +import type { Server, CreateServerData } from '../../types/server'; +import { ServerForm } from './ServerForm'; + +interface ServerListProps { + servers: Server[]; + onUpdate: (id: number, data: CreateServerData) => void; + onDelete: (id: number) => void; +} + +export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) { + const [editingId, setEditingId] = useState(null); + const [testingConnections, setTestingConnections] = useState>(new Set()); + const [connectionResults, setConnectionResults] = useState>(new Map()); + + const handleEdit = (server: Server) => { + setEditingId(server.id); + }; + + const handleUpdate = (data: CreateServerData) => { + if (editingId) { + onUpdate(editingId, data); + setEditingId(null); + } + }; + + const handleCancel = () => { + setEditingId(null); + }; + + const handleDelete = (id: number) => { + if (window.confirm('Are you sure you want to delete this server configuration?')) { + onDelete(id); + } + }; + + const handleTestConnection = async (server: Server) => { + setTestingConnections(prev => new Set(prev).add(server.id)); + setConnectionResults(prev => { + const newMap = new Map(prev); + newMap.delete(server.id); + return newMap; + }); + + try { + const response = await fetch(`/api/servers/${server.id}/test-connection`, { + method: 'POST', + }); + + const result = await response.json(); + + setConnectionResults(prev => new Map(prev).set(server.id, { + success: result.success, + message: result.message + })); + } catch { + setConnectionResults(prev => new Map(prev).set(server.id, { + success: false, + message: 'Failed to test connection - network error' + })); + } finally { + setTestingConnections(prev => { + const newSet = new Set(prev); + newSet.delete(server.id); + return newSet; + }); + } + }; + + if (servers.length === 0) { + return ( +
+ + + +

No servers configured

+

Get started by adding a new server configuration above.

+
+ ); + } + + return ( +
+ {servers.map((server) => ( +
+ {editingId === server.id ? ( +
+

Edit Server

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

{server.name}

+
+ + + + + {server.ip} + + + + + + {server.user} + +
+
+ Created: {new Date(server.created_at).toLocaleDateString()} + {server.updated_at !== server.created_at && ( + • Updated: {new Date(server.updated_at).toLocaleDateString()} + )} +
+ + {/* Connection Test Result */} + {connectionResults.has(server.id) && ( +
+
+ {connectionResults.get(server.id)?.success ? ( + + + + ) : ( + + + + )} + + {connectionResults.get(server.id)?.success ? 'Connection Successful' : 'Connection Failed'} + +
+

{connectionResults.get(server.id)?.message}

+
+ )} +
+
+
+
+ + + +
+
+ )} +
+ ))} +
+ ); +} + diff --git a/src/app/_components/SettingsButton.tsx b/src/app/_components/SettingsButton.tsx new file mode 100644 index 0000000..0d26422 --- /dev/null +++ b/src/app/_components/SettingsButton.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useState } from 'react'; +import { SettingsModal } from './SettingsModal'; + +export function SettingsButton() { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + + + setIsOpen(false)} /> + + ); +} + diff --git a/src/app/_components/SettingsModal.tsx b/src/app/_components/SettingsModal.tsx new file mode 100644 index 0000000..12d9d76 --- /dev/null +++ b/src/app/_components/SettingsModal.tsx @@ -0,0 +1,196 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import type { Server, CreateServerData } from '../../types/server'; +import { ServerForm } from './ServerForm'; +import { ServerList } from './ServerList'; + +interface SettingsModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { + const [servers, setServers] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState<'servers' | 'general'>('servers'); + + useEffect(() => { + if (isOpen) { + void fetchServers(); + } + }, [isOpen]); + + const fetchServers = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch('/api/servers'); + if (!response.ok) { + throw new Error('Failed to fetch servers'); + } + const data = await response.json(); + setServers(data as Server[]); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + } finally { + setLoading(false); + } + }; + + const handleCreateServer = async (serverData: CreateServerData) => { + try { + const response = await fetch('/api/servers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(serverData), + }); + + if (!response.ok) { + throw new Error('Failed to create server'); + } + + await fetchServers(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create server'); + } + }; + + const handleUpdateServer = async (id: number, serverData: CreateServerData) => { + try { + const response = await fetch(`/api/servers/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(serverData), + }); + + if (!response.ok) { + throw new Error('Failed to update server'); + } + + await fetchServers(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update server'); + } + }; + + const handleDeleteServer = async (id: number) => { + try { + const response = await fetch(`/api/servers/${id}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error('Failed to delete server'); + } + + await fetchServers(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete server'); + } + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+

Settings

+ +
+ + {/* Tabs */} +
+ +
+ + {/* Content */} +
+ {error && ( +
+
+
+ + + +
+
+

Error

+
{error}
+
+
+
+ )} + + {activeTab === 'servers' && ( +
+
+

Server Configurations

+ +
+ +
+

Saved Servers

+ {loading ? ( +
+
+

Loading servers...

+
+ ) : ( + + )} +
+
+ )} + + {activeTab === 'general' && ( +
+

General Settings

+

General settings will be available in a future update.

+
+ )} +
+
+
+ ); +} + diff --git a/src/app/_components/Terminal.tsx b/src/app/_components/Terminal.tsx index 57392d3..14325d8 100644 --- a/src/app/_components/Terminal.tsx +++ b/src/app/_components/Terminal.tsx @@ -6,6 +6,8 @@ import '@xterm/xterm/css/xterm.css'; interface TerminalProps { scriptPath: string; onClose: () => void; + mode?: 'local' | 'ssh'; + server?: any; } interface TerminalMessage { @@ -14,7 +16,7 @@ interface TerminalMessage { timestamp: number; } -export function Terminal({ scriptPath, onClose }: TerminalProps) { +export function Terminal({ scriptPath, onClose, mode = 'local', server }: TerminalProps) { const [isConnected, setIsConnected] = useState(false); const [isRunning, setIsRunning] = useState(false); const [isClient, setIsClient] = useState(false); @@ -58,6 +60,12 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) { cursorStyle: 'block', scrollback: 1000, tabStopWidth: 4, + allowTransparency: false, + convertEol: true, + disableStdin: false, + macOptionIsMeta: false, + rightClickSelectsWord: false, + wordSeparator: ' ()[]{}\'"`<>|', }); // Add addons @@ -148,11 +156,15 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) { isConnectingRef.current = false; // Send start message immediately after connection - ws.send(JSON.stringify({ + const message = { action: 'start', scriptPath, - executionId - })); + executionId, + mode, + server + }; + console.log('Sending WebSocket message:', message); + ws.send(JSON.stringify(message)); }; ws.onmessage = (event) => { @@ -189,7 +201,7 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) { wsRef.current.close(); } }; - }, [scriptPath, executionId]); + }, [scriptPath, executionId, mode, server]); const handleMessage = (message: TerminalMessage) => { if (!xtermRef.current) return; @@ -211,6 +223,12 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) { if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) { // This is likely terminal output sent to stderr, treat it as normal output xtermRef.current.write(message.data); + } else if (message.data.includes('TERM environment variable not set')) { + // This is a common warning, treat as normal output + xtermRef.current.write(message.data); + } else if (message.data.includes('exit code') && message.data.includes('clear')) { + // This is a script error, show it with error prefix + xtermRef.current.writeln(`${prefix}❌ ${message.data}`); } else { // This is a real error, show it with error prefix xtermRef.current.writeln(`${prefix}❌ ${message.data}`); @@ -228,7 +246,9 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) { wsRef.current.send(JSON.stringify({ action: 'start', scriptPath, - executionId + executionId, + mode, + server })); } }; @@ -282,7 +302,7 @@ export function Terminal({ scriptPath, onClose }: TerminalProps) {
- {scriptName} + {scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`} diff --git a/src/app/api/servers/[id]/route.ts b/src/app/api/servers/[id]/route.ts new file mode 100644 index 0000000..c1a51d8 --- /dev/null +++ b/src/app/api/servers/[id]/route.ts @@ -0,0 +1,143 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { getDatabase } from '../../../../server/database'; +import type { CreateServerData } from '../../../../types/server'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: idParam } = await params; + const id = parseInt(idParam); + if (isNaN(id)) { + return NextResponse.json( + { error: 'Invalid server ID' }, + { status: 400 } + ); + } + + const db = getDatabase(); + const server = db.getServerById(id); + + if (!server) { + return NextResponse.json( + { error: 'Server not found' }, + { status: 404 } + ); + } + + return NextResponse.json(server); + } catch (error) { + console.error('Error fetching server:', error); + return NextResponse.json( + { error: 'Failed to fetch server' }, + { status: 500 } + ); + } +} + +export async function PUT( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: idParam } = await params; + const id = parseInt(idParam); + if (isNaN(id)) { + return NextResponse.json( + { error: 'Invalid server ID' }, + { status: 400 } + ); + } + + const body = await request.json(); + const { name, ip, user, password }: CreateServerData = body; + + // Validate required fields + if (!name || !ip || !user || !password) { + return NextResponse.json( + { error: 'Missing required fields' }, + { status: 400 } + ); + } + + const db = getDatabase(); + + // Check if server exists + const existingServer = db.getServerById(id); + if (!existingServer) { + return NextResponse.json( + { error: 'Server not found' }, + { status: 404 } + ); + } + + const result = db.updateServer(id, { name, ip, user, password }); + + return NextResponse.json( + { + message: 'Server updated successfully', + changes: result.changes + } + ); + } catch (error) { + console.error('Error updating server:', error); + + // Handle unique constraint violation + if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) { + return NextResponse.json( + { error: 'A server with this name already exists' }, + { status: 409 } + ); + } + + return NextResponse.json( + { error: 'Failed to update server' }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: idParam } = await params; + const id = parseInt(idParam); + if (isNaN(id)) { + return NextResponse.json( + { error: 'Invalid server ID' }, + { status: 400 } + ); + } + + const db = getDatabase(); + + // Check if server exists + const existingServer = db.getServerById(id); + if (!existingServer) { + return NextResponse.json( + { error: 'Server not found' }, + { status: 404 } + ); + } + + const result = db.deleteServer(id); + + return NextResponse.json( + { + message: 'Server deleted successfully', + changes: result.changes + } + ); + } catch (error) { + console.error('Error deleting server:', error); + return NextResponse.json( + { error: 'Failed to delete server' }, + { status: 500 } + ); + } +} + diff --git a/src/app/api/servers/[id]/test-connection/route.ts b/src/app/api/servers/[id]/test-connection/route.ts new file mode 100644 index 0000000..4237ec6 --- /dev/null +++ b/src/app/api/servers/[id]/test-connection/route.ts @@ -0,0 +1,44 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { getDatabase } from '../../../../../server/database'; +import { getSSHService } from '../../../../../server/ssh-service'; +import type { Server } from '../../../../../types/server'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: idParam } = await params; + const id = parseInt(idParam); + if (isNaN(id)) { + return NextResponse.json( + { error: 'Invalid server ID' }, + { status: 400 } + ); + } + + const db = getDatabase(); + const server = db.getServerById(id) as Server; + + if (!server) { + return NextResponse.json( + { error: 'Server not found' }, + { status: 404 } + ); + } + + // Test SSH connection + const sshService = getSSHService(); + const connectionResult = await sshService.testConnection(server); + + return NextResponse.json(connectionResult); + } catch (error) { + console.error('Error testing SSH connection:', error); + return NextResponse.json( + { error: 'Failed to test SSH connection' }, + { status: 500 } + ); + } +} + diff --git a/src/app/api/servers/route.ts b/src/app/api/servers/route.ts new file mode 100644 index 0000000..8d0088a --- /dev/null +++ b/src/app/api/servers/route.ts @@ -0,0 +1,60 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { getDatabase } from '../../../server/database'; +import type { CreateServerData } from '../../../types/server'; + +export async function GET() { + try { + const db = getDatabase(); + const servers = db.getAllServers(); + return NextResponse.json(servers); + } catch (error) { + console.error('Error fetching servers:', error); + return NextResponse.json( + { error: 'Failed to fetch servers' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { name, ip, user, password }: CreateServerData = body; + + // Validate required fields + if (!name || !ip || !user || !password) { + return NextResponse.json( + { error: 'Missing required fields' }, + { status: 400 } + ); + } + + const db = getDatabase(); + const result = db.createServer({ name, ip, user, password }); + + return NextResponse.json( + { + message: 'Server created successfully', + id: result.lastInsertRowid + }, + { status: 201 } + ); + } catch (error) { + console.error('Error creating server:', error); + + // Handle unique constraint violation + if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) { + return NextResponse.json( + { error: 'A server with this name already exists' }, + { status: 409 } + ); + } + + return NextResponse.json( + { error: 'Failed to create server' }, + { status: 500 } + ); + } +} + diff --git a/src/app/page.tsx b/src/app/page.tsx index daf5f7e..ab1ff8d 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,15 +4,15 @@ import { useState } from 'react'; import { ScriptsGrid } from './_components/ScriptsGrid'; import { ResyncButton } from './_components/ResyncButton'; -import { RepoStatusButton } from './_components/RepoStatusButton'; import { Terminal } from './_components/Terminal'; -import { ProxmoxCheck } from './_components/ProxmoxCheck'; +import { SettingsButton } from './_components/SettingsButton'; export default function Home() { - const [runningScript, setRunningScript] = useState<{ path: string; name: string } | null>(null); + const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null); - const handleRunScript = (scriptPath: string, scriptName: string) => { - setRunningScript({ path: scriptPath, name: scriptName }); + const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => { + console.log('handleRunScript called with:', { scriptPath, scriptName, mode, server }); + setRunningScript({ path: scriptPath, name: scriptName, mode, server }); }; const handleCloseTerminal = () => { @@ -20,43 +20,43 @@ export default function Home() { }; return ( - -
-
- {/* Header */} -
-

- 🚀 PVE Scripts Management -

-

- Manage and execute Proxmox helper scripts locally with live output streaming -

-
+
+
+ {/* Header */} +
+

+ 🚀 PVE Scripts Management +

+

+ Manage and execute Proxmox helper scripts locally with live output streaming +

+
- {/* Resync Button */} -
-
-
- -
+ {/* Controls */} +
+
+ +
- - {/* Running Script Terminal */} - {runningScript && ( -
- -
- )} - - {/* Scripts List */} -
-
- + + {/* Running Script Terminal */} + {runningScript && ( +
+ +
+ )} + + {/* Scripts List */} + +
+
); } diff --git a/src/server/api/websocket/handler.ts b/src/server/api/websocket/handler.ts index de4db62..6f60d2f 100644 --- a/src/server/api/websocket/handler.ts +++ b/src/server/api/websocket/handler.ts @@ -1,6 +1,8 @@ import { WebSocketServer, WebSocket } from 'ws'; import type { IncomingMessage } from 'http'; import { scriptManager } from '~/server/lib/scripts'; +import { getSSHExecutionService } from '~/server/ssh-execution-service'; +import type { Server } from '~/types/server'; interface ScriptExecutionMessage { type: 'start' | 'output' | 'error' | 'end'; @@ -48,13 +50,15 @@ export class ScriptExecutionHandler { }); } - private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string }) { - const { action, scriptPath, executionId } = message; + private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any }) { + const { action, scriptPath, executionId, mode, server } = message; + + console.log('WebSocket message received:', { action, scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null }); switch (action) { case 'start': if (scriptPath && executionId) { - await this.startScriptExecution(ws, scriptPath, executionId); + await this.startScriptExecution(ws, scriptPath, executionId, mode, server); } else { this.sendMessage(ws, { type: 'error', @@ -79,19 +83,10 @@ export class ScriptExecutionHandler { } } - private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string) { + private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string, mode?: 'local' | 'ssh', server?: any) { + console.log('startScriptExecution called with:', { scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null }); + try { - // Validate script path - const validation = scriptManager.validateScriptPath(scriptPath); - if (!validation.valid) { - this.sendMessage(ws, { - type: 'error', - data: validation.message ?? 'Invalid script path', - timestamp: Date.now() - }); - return; - } - // Check if execution is already running if (this.activeExecutions.has(executionId)) { this.sendMessage(ws, { @@ -102,61 +97,135 @@ export class ScriptExecutionHandler { return; } - // Start script execution - const process = await scriptManager.executeScript(scriptPath); + let process: any; + + if (mode === 'ssh' && server) { + // SSH execution + console.log('Starting SSH execution:', { scriptPath, server }); + console.log('SSH execution mode detected, calling SSH service...'); + console.log('Mode check: mode=', mode, 'server=', !!server); + this.sendMessage(ws, { + type: 'start', + data: `Starting SSH execution of ${scriptPath} on ${server.name ?? server.ip}`, + timestamp: Date.now() + }); + + const sshService = getSSHExecutionService(); + console.log('SSH service obtained, calling executeScript...'); + console.log('SSH service object:', typeof sshService, sshService.constructor.name); + + try { + const result = await sshService.executeScript(server as Server, scriptPath, + (data: string) => { + console.log('SSH onData callback:', data.substring(0, 100) + '...'); + this.sendMessage(ws, { + type: 'output', + data: data, + timestamp: Date.now() + }); + }, + (error: string) => { + console.log('SSH onError callback:', error); + this.sendMessage(ws, { + type: 'error', + data: error, + timestamp: Date.now() + }); + }, + (code: number) => { + console.log('SSH onExit callback, code:', code); + this.sendMessage(ws, { + type: 'end', + data: `SSH script execution finished with code: ${code}`, + timestamp: Date.now() + }); + this.activeExecutions.delete(executionId); + } + ); + console.log('SSH service executeScript completed, result:', result); + process = (result as any).process; + } catch (sshError) { + console.error('SSH service executeScript failed:', sshError); + this.sendMessage(ws, { + type: 'error', + data: `SSH execution failed: ${sshError instanceof Error ? sshError.message : String(sshError)}`, + timestamp: Date.now() + }); + return; + } + } else { + // Local execution + console.log('Starting local execution:', { scriptPath }); + console.log('Local execution mode detected, calling local script manager...'); + console.log('Mode check: mode=', mode, 'server=', !!server, 'condition result:', mode === 'ssh' && server); + + // Validate script path + const validation = scriptManager.validateScriptPath(scriptPath); + if (!validation.valid) { + this.sendMessage(ws, { + type: 'error', + data: validation.message ?? 'Invalid script path', + timestamp: Date.now() + }); + return; + } + + // Start script execution + process = await scriptManager.executeScript(scriptPath); + + // Send start message + this.sendMessage(ws, { + type: 'start', + data: `Starting execution of ${scriptPath}`, + timestamp: Date.now() + }); + + // Handle stdout + process.stdout?.on('data', (data: Buffer) => { + this.sendMessage(ws, { + type: 'output', + data: data.toString(), + timestamp: Date.now() + }); + }); + + // Handle stderr + process.stderr?.on('data', (data: Buffer) => { + this.sendMessage(ws, { + type: 'error', + data: data.toString(), + timestamp: Date.now() + }); + }); + + // Handle process exit + process.on('exit', (code: number | null, signal: string | null) => { + this.sendMessage(ws, { + type: 'end', + data: `Script execution finished with code: ${code}, signal: ${signal}`, + timestamp: Date.now() + }); + + // Clean up + this.activeExecutions.delete(executionId); + }); + + // Handle process error + process.on('error', (error: Error) => { + this.sendMessage(ws, { + type: 'error', + data: `Process error: ${error.message}`, + timestamp: Date.now() + }); + + // Clean up + this.activeExecutions.delete(executionId); + }); + } // Store the execution this.activeExecutions.set(executionId, { process, ws }); - // Send start message - this.sendMessage(ws, { - type: 'start', - data: `Starting execution of ${scriptPath}`, - timestamp: Date.now() - }); - - // Handle stdout - process.stdout?.on('data', (data: Buffer) => { - this.sendMessage(ws, { - type: 'output', - data: data.toString(), - timestamp: Date.now() - }); - }); - - // Handle stderr - process.stderr?.on('data', (data: Buffer) => { - this.sendMessage(ws, { - type: 'error', - data: data.toString(), - timestamp: Date.now() - }); - }); - - // Handle process exit - process.on('exit', (code: number | null, signal: string | null) => { - this.sendMessage(ws, { - type: 'end', - data: `Script execution finished with code: ${code}, signal: ${signal}`, - timestamp: Date.now() - }); - - // Clean up - this.activeExecutions.delete(executionId); - }); - - // Handle process error - process.on('error', (error: Error) => { - this.sendMessage(ws, { - type: 'error', - data: `Process error: ${error.message}`, - timestamp: Date.now() - }); - - // Clean up - this.activeExecutions.delete(executionId); - }); - } catch (error) { this.sendMessage(ws, { type: 'error', diff --git a/src/server/database.js b/src/server/database.js new file mode 100644 index 0000000..2ab0e3a --- /dev/null +++ b/src/server/database.js @@ -0,0 +1,100 @@ +import Database from 'better-sqlite3'; +import { join } from 'path'; + +class DatabaseService { + constructor() { + const dbPath = join(process.cwd(), 'data', 'settings.db'); + this.db = new Database(dbPath); + this.init(); + } + + init() { + // Create servers table if it doesn't exist + this.db.exec(` + CREATE TABLE IF NOT EXISTS servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + ip TEXT NOT NULL, + user TEXT NOT NULL, + password TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Create trigger to update updated_at on row update + this.db.exec(` + CREATE TRIGGER IF NOT EXISTS update_servers_timestamp + AFTER UPDATE ON servers + BEGIN + UPDATE servers SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END + `); + } + + // Server CRUD operations + /** + * @param {import('../types/server').CreateServerData} serverData + */ + createServer(serverData) { + const { name, ip, user, password } = serverData; + const stmt = this.db.prepare(` + INSERT INTO servers (name, ip, user, password) + VALUES (?, ?, ?, ?) + `); + return stmt.run(name, ip, user, password); + } + + getAllServers() { + const stmt = this.db.prepare('SELECT * FROM servers ORDER BY created_at DESC'); + return stmt.all(); + } + + /** + * @param {number} id + */ + getServerById(id) { + const stmt = this.db.prepare('SELECT * FROM servers WHERE id = ?'); + return stmt.get(id); + } + + /** + * @param {number} id + * @param {import('../types/server').CreateServerData} serverData + */ + updateServer(id, serverData) { + const { name, ip, user, password } = serverData; + const stmt = this.db.prepare(` + UPDATE servers + SET name = ?, ip = ?, user = ?, password = ? + WHERE id = ? + `); + return stmt.run(name, ip, user, password, id); + } + + /** + * @param {number} id + */ + deleteServer(id) { + const stmt = this.db.prepare('DELETE FROM servers WHERE id = ?'); + return stmt.run(id); + } + + close() { + this.db.close(); + } +} + +// Singleton instance +/** @type {DatabaseService | null} */ +let dbInstance = null; + +export function getDatabase() { + if (!dbInstance) { + dbInstance = new DatabaseService(); + } + return dbInstance; +} + +export default DatabaseService; + diff --git a/src/server/ssh-execution-service.js b/src/server/ssh-execution-service.js new file mode 100644 index 0000000..29b3fdf --- /dev/null +++ b/src/server/ssh-execution-service.js @@ -0,0 +1,157 @@ +import { spawn } from 'child_process'; +import { spawn as ptySpawn } from 'node-pty'; + + +/** + * @typedef {Object} Server + * @property {string} ip - Server IP address + * @property {string} user - Username + * @property {string} password - Password + * @property {string} name - Server name + */ + +class SSHExecutionService { + /** + * Execute a script on a remote server via SSH + * @param {Server} server - Server configuration + * @param {string} scriptPath - Path to the script + * @param {Function} onData - Callback for data output + * @param {Function} onError - Callback for errors + * @param {Function} onExit - Callback for process exit + * @returns {Promise} Process information + */ + async executeScript(server, scriptPath, onData, onError, onExit) { + const { ip, user, password } = server; + + try { + await this.transferScriptsFolder(server, onData, onError); + + return new Promise((resolve, reject) => { + const relativeScriptPath = scriptPath.startsWith('scripts/') ? scriptPath.substring(8) : scriptPath; + + // Use ptySpawn for proper terminal emulation and color support + const sshCommand = ptySpawn('sshpass', [ + '-p', password, + 'ssh', + '-t', + '-o', 'ConnectTimeout=10', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'LogLevel=ERROR', + '-o', 'PasswordAuthentication=yes', + '-o', 'PubkeyAuthentication=no', + '-o', 'RequestTTY=yes', + '-o', 'SetEnv=TERM=xterm-256color', + '-o', 'SetEnv=COLUMNS=120', + '-o', 'SetEnv=LINES=30', + '-o', 'SetEnv=COLORTERM=truecolor', + '-o', 'SetEnv=FORCE_COLOR=1', + '-o', 'SetEnv=NO_COLOR=0', + '-o', 'SetEnv=CLICOLOR=1', + '-o', 'SetEnv=CLICOLOR_FORCE=1', + `${user}@${ip}`, + `cd /tmp/scripts && chmod +x ${relativeScriptPath} && export TERM=xterm-256color && export COLUMNS=120 && export LINES=30 && export COLORTERM=truecolor && export FORCE_COLOR=1 && export NO_COLOR=0 && export CLICOLOR=1 && export CLICOLOR_FORCE=1 && bash ${relativeScriptPath}` + ], { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: process.cwd(), + env: { + ...process.env, + TERM: 'xterm-256color', + COLUMNS: '120', + LINES: '30', + SHELL: '/bin/bash', + COLORTERM: 'truecolor', + FORCE_COLOR: '1', + NO_COLOR: '0', + CLICOLOR: '1', + CLICOLOR_FORCE: '1' + } + }); + + // Use pty's onData method which handles both stdout and stderr combined + sshCommand.onData((data) => { + // pty handles encoding automatically and preserves ANSI codes + onData(data); + }); + + sshCommand.onExit((e) => { + onExit(e.exitCode); + }); + + resolve({ + process: sshCommand, + kill: () => sshCommand.kill('SIGTERM') + }); + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + onError(`SSH execution failed: ${errorMessage}`); + throw error; + } + } + + /** + * Transfer the entire scripts folder to the remote server + * @param {Server} server - Server configuration + * @param {Function} onData - Callback for data output + * @param {Function} onError - Callback for errors + * @returns {Promise} + */ + async transferScriptsFolder(server, onData, onError) { + const { ip, user, password } = server; + + return new Promise((resolve, reject) => { + const rsyncCommand = spawn('rsync', [ + '-avz', + '--delete', + '--exclude=*.log', + '--exclude=*.tmp', + '--rsh=sshpass -p ' + password + ' ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null', + 'scripts/', + `${user}@${ip}:/tmp/scripts/` + ], { + stdio: ['pipe', 'pipe', 'pipe'] + }); + + rsyncCommand.stdout.on('data', (/** @type {Buffer} */ data) => { + // Ensure proper UTF-8 encoding for ANSI colors + const output = data.toString('utf8'); + onData(output); + }); + + rsyncCommand.stderr.on('data', (/** @type {Buffer} */ data) => { + // Ensure proper UTF-8 encoding for ANSI colors + const output = data.toString('utf8'); + onError(output); + }); + + rsyncCommand.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`rsync failed with code ${code}`)); + } + }); + + rsyncCommand.on('error', (error) => { + reject(error); + }); + }); + } + +} + +// Singleton instance +/** @type {SSHExecutionService | null} */ +let sshExecutionInstance = null; + +export function getSSHExecutionService() { + if (!sshExecutionInstance) { + sshExecutionInstance = new SSHExecutionService(); + } + return sshExecutionInstance; +} + +export default SSHExecutionService; \ No newline at end of file diff --git a/src/server/ssh-service.js b/src/server/ssh-service.js new file mode 100644 index 0000000..99ee3dc --- /dev/null +++ b/src/server/ssh-service.js @@ -0,0 +1,539 @@ +import { spawn } from 'child_process'; +import { writeFileSync, unlinkSync, chmodSync } from 'fs'; +import { join } from 'path'; + +class SSHService { + /** + * Test SSH connection with actual login verification + * This method tests if the user can actually log in with the provided credentials + * @param {import('../types/server').Server} server - Server configuration + * @returns {Promise} Connection test result + */ + async testConnection(server) { + const { ip, user, password } = server; + + return new Promise((resolve) => { + const timeout = 15000; // 15 seconds timeout for login test + let resolved = false; + + // Try sshpass first if available + this.testWithSshpass(server).then(result => { + if (!resolved) { + resolved = true; + resolve(result); + } + }).catch(() => { + // If sshpass fails, try expect + this.testWithExpect(server).then(result => { + if (!resolved) { + resolved = true; + resolve(result); + } + }).catch(() => { + // If both fail, return error + if (!resolved) { + resolved = true; + resolve({ + success: false, + message: 'SSH login test requires sshpass or expect - neither available or working', + details: { + method: 'no_auth_tools' + } + }); + } + }); + }); + + // Set up overall timeout + setTimeout(() => { + if (!resolved) { + resolved = true; + resolve({ + success: false, + message: 'SSH login test timeout - server did not respond within 15 seconds', + details: { timeout: true, method: 'ssh_login_test' } + }); + } + }, timeout); + }); + } + + /** + * Test SSH connection using sshpass + * @param {import('../types/server').Server} server - Server configuration + * @returns {Promise} Connection test result + */ + async testWithSshpass(server) { + const { ip, user, password } = server; + + return new Promise((resolve, reject) => { + const timeout = 10000; + let resolved = false; + + const sshCommand = spawn('sshpass', [ + '-p', password, + 'ssh', + '-o', 'ConnectTimeout=10', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'LogLevel=ERROR', + '-o', 'PasswordAuthentication=yes', + '-o', 'PubkeyAuthentication=no', + `${user}@${ip}`, + 'echo "SSH_LOGIN_SUCCESS"' + ], { + stdio: ['pipe', 'pipe', 'pipe'] + }); + + const timer = setTimeout(() => { + if (!resolved) { + resolved = true; + sshCommand.kill('SIGTERM'); + reject(new Error('SSH login timeout')); + } + }, timeout); + + let output = ''; + let errorOutput = ''; + + sshCommand.stdout.on('data', (data) => { + output += data.toString(); + }); + + sshCommand.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + sshCommand.on('close', (code) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + + if (code === 0 && output.includes('SSH_LOGIN_SUCCESS')) { + resolve({ + success: true, + message: 'SSH login successful - credentials verified', + details: { + server: server.name || 'Unknown', + ip: ip, + user: user, + method: 'sshpass_verified' + } + }); + } else { + let errorMessage = 'SSH login failed'; + + if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) { + errorMessage = 'Authentication failed - check username and password'; + } else if (errorOutput.includes('Connection refused')) { + errorMessage = 'Connection refused - server may be down or SSH not running'; + } else if (errorOutput.includes('Name or service not known') || errorOutput.includes('No route to host')) { + errorMessage = 'Host not found - check IP address'; + } else if (errorOutput.includes('Connection timed out')) { + errorMessage = 'Connection timeout - server may be unreachable'; + } else { + errorMessage = `SSH login failed: ${errorOutput.trim()}`; + } + + reject(new Error(errorMessage)); + } + } + }); + + sshCommand.on('error', (error) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + reject(error); + } + }); + }); + } + + /** + * Test SSH connection using expect + * @param {import('../types/server').Server} server - Server configuration + * @returns {Promise} Connection test result + */ + async testWithExpect(server) { + const { ip, user, password } = server; + + return new Promise((resolve, reject) => { + const timeout = 10000; + let resolved = false; + + const expectScript = `#!/usr/bin/expect -f +set timeout 10 +spawn ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o PasswordAuthentication=yes -o PubkeyAuthentication=no ${user}@${ip} "echo SSH_LOGIN_SUCCESS" +expect { + "password:" { + send "${password}\r" + exp_continue + } + "Password:" { + send "${password}\r" + exp_continue + } + "SSH_LOGIN_SUCCESS" { + exit 0 + } + timeout { + exit 1 + } + eof { + exit 1 + } +}`; + + const expectCommand = spawn('expect', ['-c', expectScript], { + stdio: ['pipe', 'pipe', 'pipe'] + }); + + const timer = setTimeout(() => { + if (!resolved) { + resolved = true; + expectCommand.kill('SIGTERM'); + reject(new Error('SSH login timeout')); + } + }, timeout); + + let output = ''; + let errorOutput = ''; + + expectCommand.stdout.on('data', (data) => { + output += data.toString(); + }); + + expectCommand.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + expectCommand.on('close', (code) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + + if (code === 0) { + resolve({ + success: true, + message: 'SSH login successful - credentials verified', + details: { + server: server.name || 'Unknown', + ip: ip, + user: user, + method: 'expect_verified' + } + }); + } else { + let errorMessage = 'SSH login failed'; + + if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) { + errorMessage = 'Authentication failed - check username and password'; + } else if (errorOutput.includes('Connection refused')) { + errorMessage = 'Connection refused - server may be down or SSH not running'; + } else if (errorOutput.includes('Name or service not known') || errorOutput.includes('No route to host')) { + errorMessage = 'Host not found - check IP address'; + } else if (errorOutput.includes('Connection timed out')) { + errorMessage = 'Connection timeout - server may be unreachable'; + } else { + errorMessage = `SSH login failed: ${errorOutput.trim()}`; + } + + reject(new Error(errorMessage)); + } + } + }); + + expectCommand.on('error', (error) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + reject(error); + } + }); + }); + } + + /** + * Test SSH connection using basic connectivity check (fallback method) + * This method tests if the SSH port is open and reachable + * @param {import('../types/server').Server} server - Server configuration + * @returns {Promise} Connection test result + */ + async testConnectionBasic(server) { + const { ip, user, password } = server; + + return new Promise((resolve) => { + const timeout = 10000; // 10 seconds timeout + let resolved = false; + + // First, test if the SSH port is open using netcat or telnet + const portTestCommand = spawn('nc', ['-z', '-w', '5', ip, '22'], { + stdio: ['pipe', 'pipe', 'pipe'] + }); + + // Set up timeout + const timer = setTimeout(() => { + if (!resolved) { + resolved = true; + portTestCommand.kill('SIGTERM'); + resolve({ + success: false, + message: 'Connection timeout - server did not respond within 10 seconds', + details: { timeout: true, method: 'port_check' } + }); + } + }, timeout); + + // Handle port test results + portTestCommand.on('close', (code) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + + if (code === 0) { + // Port is open, now try a basic SSH connection test + this.testSSHConnection(server).then(resolve).catch(() => { + resolve({ + success: false, + message: 'SSH port is open but connection failed - check credentials', + details: { + portOpen: true, + method: 'ssh_connection_test' + } + }); + }); + } else { + resolve({ + success: false, + message: 'SSH port (22) is not accessible - server may be down or SSH not running', + details: { + portOpen: false, + exitCode: code, + method: 'port_check' + } + }); + } + } + }); + + // Handle port test errors + portTestCommand.on('error', (error) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + + // If netcat is not available, try with telnet + this.testWithTelnet(server).then(resolve).catch(() => { + resolve({ + success: false, + message: 'Cannot test SSH connectivity - netcat and telnet not available', + details: { + error: error.message, + method: 'port_check_fallback' + } + }); + }); + } + }); + }); + } + + /** + * Test SSH connection using telnet as fallback + * @param {import('../types/server').Server} server - Server configuration + * @returns {Promise} Connection test result + */ + async testWithTelnet(server) { + const { ip } = server; + + return new Promise((resolve) => { + const timeout = 5000; + let resolved = false; + + const telnetCommand = spawn('timeout', ['5', 'telnet', ip, '22'], { + stdio: ['pipe', 'pipe', 'pipe'] + }); + + const timer = setTimeout(() => { + if (!resolved) { + resolved = true; + telnetCommand.kill('SIGTERM'); + resolve({ + success: false, + message: 'SSH port test timeout', + details: { method: 'telnet_timeout' } + }); + } + }, timeout); + + let output = ''; + + telnetCommand.stdout.on('data', (data) => { + output += data.toString(); + }); + + telnetCommand.stderr.on('data', (data) => { + output += data.toString(); + }); + + telnetCommand.on('close', (code) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + + if (output.includes('Connected') || output.includes('SSH')) { + resolve({ + success: true, + message: 'SSH port is accessible - basic connectivity confirmed', + details: { + portOpen: true, + method: 'telnet_test' + } + }); + } else { + resolve({ + success: false, + message: 'SSH port is not accessible', + details: { + portOpen: false, + output: output.trim(), + method: 'telnet_test' + } + }); + } + } + }); + + telnetCommand.on('error', (error) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + resolve({ + success: false, + message: 'Cannot test SSH connectivity - required tools not available', + details: { + error: error.message, + method: 'telnet_error' + } + }); + } + }); + }); + } + + /** + * Test actual SSH connection (without password authentication) + * @param {import('../types/server').Server} server - Server configuration + * @returns {Promise} Connection test result + */ + async testSSHConnection(server) { + const { ip, user } = server; + + return new Promise((resolve) => { + const timeout = 5000; + let resolved = false; + + const sshCommand = spawn('ssh', [ + '-o', 'ConnectTimeout=5', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'LogLevel=ERROR', + '-o', 'PasswordAuthentication=no', + '-o', 'PubkeyAuthentication=no', + '-o', 'PreferredAuthentications=none', + `${user}@${ip}`, + 'exit' + ], { + stdio: ['pipe', 'pipe', 'pipe'] + }); + + const timer = setTimeout(() => { + if (!resolved) { + resolved = true; + sshCommand.kill('SIGTERM'); + resolve({ + success: false, + message: 'SSH connection timeout', + details: { method: 'ssh_timeout' } + }); + } + }, timeout); + + let errorOutput = ''; + + sshCommand.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + sshCommand.on('close', (code) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + + // SSH connection was established but authentication failed + // This is actually a good sign - it means SSH is working + if (errorOutput.includes('Permission denied') || errorOutput.includes('Authentication failed')) { + resolve({ + success: true, + message: 'SSH service is running and accessible - authentication required', + details: { + server: server.name || 'Unknown', + ip: ip, + user: user, + method: 'ssh_auth_required' + } + }); + } else if (errorOutput.includes('Connection refused')) { + resolve({ + success: false, + message: 'SSH connection refused - service may not be running', + details: { + error: errorOutput.trim(), + method: 'ssh_connection_refused' + } + }); + } else { + resolve({ + success: false, + message: `SSH connection failed: ${errorOutput.trim()}`, + details: { + error: errorOutput.trim(), + method: 'ssh_connection_failed' + } + }); + } + } + }); + + sshCommand.on('error', (error) => { + if (!resolved) { + resolved = true; + clearTimeout(timer); + resolve({ + success: false, + message: `SSH command failed: ${error.message}`, + details: { + error: error.message, + method: 'ssh_command_error' + } + }); + } + }); + }); + } + +} + +// Singleton instance +/** @type {SSHService | null} */ +let sshInstance = null; + +export function getSSHService() { + if (!sshInstance) { + sshInstance = new SSHService(); + } + return sshInstance; +} + +export default SSHService; diff --git a/src/types/server.ts b/src/types/server.ts new file mode 100644 index 0000000..1b7bfbb --- /dev/null +++ b/src/types/server.ts @@ -0,0 +1,21 @@ +export interface Server { + id: number; + name: string; + ip: string; + user: string; + password: string; + created_at: string; + updated_at: string; +} + +export interface CreateServerData { + name: string; + ip: string; + user: string; + password: string; +} + +export interface UpdateServerData extends CreateServerData { + id: number; +} +