├── .dockerignore ├── start.sh ├── preview.gif ├── .gitignore ├── client ├── postcss.config.js ├── src │ ├── utils │ │ └── constants.js │ ├── main.js │ ├── components │ │ ├── LanguageSwitcher.vue │ │ ├── DarkModeToggle.vue │ │ ├── CredentialCard.vue │ │ └── CredentialFormModal.vue │ ├── stores │ │ ├── credentialStore.js │ │ ├── llmHelperStore.js │ │ ├── settingsStore.js │ │ └── sessionStore.js │ ├── router │ │ └── index.js │ ├── views │ │ ├── CredentialsView.vue │ │ ├── ForgotPasswordView.vue │ │ ├── SshDebugView.vue │ │ ├── LoginView.vue │ │ └── ResetPasswordView.vue │ └── assets │ │ └── main.css ├── index.html ├── package.json ├── vite.config.js └── tailwind.config.js ├── FUNDING.yml ├── docker-compose.yml ├── server ├── .env.example ├── src │ ├── db │ │ ├── addCredentialIdToSessionsMigration.js │ │ ├── updateProviderDescription.js │ │ ├── createCredentialsTableMigration.js │ │ ├── userProfileMigration.js │ │ ├── addTotpToUsersMigration.js │ │ ├── testCustomApi.js │ │ ├── passwordResetMigration.js │ │ ├── emailSettingsMigration.js │ │ ├── customApiMigration.js │ │ └── database.js │ ├── api │ │ ├── debug.js │ │ ├── credentials.js │ │ ├── files.js │ │ └── sessions.js │ ├── services │ │ ├── encryptionService.js │ │ ├── emailService.js │ │ └── credentialService.js │ ├── middleware │ │ └── authMiddleware.js │ ├── socket │ │ └── terminal.js │ └── app.js └── package.json ├── LICENSE ├── Dockerfile ├── docker-compose-dev.yml ├── .github └── workflows │ ├── docker-build-and-push.yml │ └── docker-build-and-publish.yml ├── docs └── LLM-HELPER.md └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | **/.env 3 | **/*.db 4 | **/*.db.bak 5 | -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | cd /app/server 5 | npm start 6 | -------------------------------------------------------------------------------- /preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clusterzx/intellissh/HEAD/preview.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .env 3 | *.db 4 | *.db.bak 5 | client/dist/ 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /client/src/utils/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Application-wide constants 3 | */ 4 | 5 | // Application version 6 | export const APP_VERSION = '1.2.0'; 7 | -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [clusterzx] 2 | ko_fi: clusterzx 3 | patreon: Clusterzx 4 | buy_me_a_coffee: clusterzx 5 | custom: ["https://www.paypal.me/bech0r"] 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | intellissh: 5 | image: clusterzx/intellissh:latest 6 | container_name: intellissh 7 | ports: 8 | - 8080:3000 9 | volumes: 10 | # Mount for persistent backend data (SQLite DB, session info, etc.) 11 | - ./data:/app/server/data 12 | restart: unless-stopped 13 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | IntelliSSH 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | # Server Configuration 2 | NODE_ENV=development 3 | PORT=3000 4 | 5 | # Database 6 | DB_PATH=./data/webssh.db 7 | 8 | # Authentication 9 | JWT_SECRET=your_jwt_secret_here_please_change_in_production 10 | JWT_EXPIRES_IN=24h 11 | 12 | # Encryption 13 | ENCRYPTION_KEY=736f4149702aae82ab6e45e64d977e3c6c1e9f7b29b368f61cafab1b9c2cc3b2 14 | 15 | # Rate Limiting 16 | RATE_LIMIT_WINDOW_MS=900000 17 | RATE_LIMIT_MAX_REQUESTS=100 18 | 19 | # LLM Helper Configuration 20 | LLM_PROVIDER=openai # openai or ollama 21 | OPENAI_API_KEY=your_openai_api_key_here 22 | OPENAI_MODEL=gpt-3.5-turbo 23 | OLLAMA_URL=http://localhost:11434 24 | OLLAMA_MODEL=llama2 25 | -------------------------------------------------------------------------------- /server/src/db/addCredentialIdToSessionsMigration.js: -------------------------------------------------------------------------------- 1 | const db = require('./database'); 2 | 3 | async function addCredentialIdToSessionsTable() { 4 | try { 5 | // Check if credential_id column exists in sessions table 6 | const tableInfo = await db.all("PRAGMA table_info(sessions)"); 7 | const credentialIdExists = tableInfo.some(column => column.name === 'credential_id'); 8 | 9 | // Add credential_id column if it doesn't exist 10 | if (!credentialIdExists) { 11 | console.log('Adding credential_id column to sessions table...'); 12 | await db.run('ALTER TABLE sessions ADD COLUMN credential_id INTEGER'); 13 | console.log('credential_id column added to sessions table.'); 14 | } else { 15 | console.log('credential_id column already exists in sessions table. No migration needed.'); 16 | } 17 | } catch (error) { 18 | console.error('Error adding credential_id to sessions table:', error.message); 19 | throw error; 20 | } 21 | } 22 | 23 | module.exports = { addCredentialIdToSessionsTable }; 24 | -------------------------------------------------------------------------------- /client/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import { createPinia } from 'pinia' 3 | import router from './router' 4 | import App from './App.vue' 5 | import './assets/main.css' 6 | import { createI18n } from 'vue-i18n' 7 | const savedLanguage = localStorage.getItem('language') || 'en' 8 | 9 | // Use import.meta.glob to load all locale messages 10 | const messages = Object.fromEntries( 11 | Object.entries(import.meta.glob('./locales/*.json', { eager: true })) 12 | .map(([key, value]) => { 13 | const locale = key.match(/\.\/locales\/(.*)\.json$/)[1] 14 | return [locale, value.default] 15 | }) 16 | ) 17 | 18 | const i18n = createI18n({ 19 | legacy: false, 20 | locale: savedLanguage, 21 | fallbackLocale: 'en', 22 | messages, // Pass the dynamically loaded messages 23 | }) 24 | 25 | const app = createApp(App) 26 | 27 | // Use Pinia for state management 28 | app.use(createPinia()) 29 | 30 | // Use Vue Router 31 | app.use(router) 32 | 33 | // Use Vue I18n 34 | app.use(i18n) 35 | 36 | // Mount the app 37 | app.mount('#app') 38 | -------------------------------------------------------------------------------- /server/src/db/updateProviderDescription.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Script to update the llm_provider description to include 'custom' option 3 | */ 4 | 5 | const db = require('./database'); 6 | 7 | async function updateProviderDescription() { 8 | try { 9 | console.log('Updating LLM provider description...'); 10 | 11 | await db.run( 12 | 'UPDATE settings SET description = ? WHERE id = ?', 13 | ['LLM provider (openai, ollama, or custom)', 'llm_provider'] 14 | ); 15 | 16 | const setting = await db.get('SELECT * FROM settings WHERE id = ?', ['llm_provider']); 17 | console.log('Updated description:', setting.description); 18 | 19 | console.log('Update completed successfully'); 20 | } catch (error) { 21 | console.error('Update failed:', error); 22 | } 23 | } 24 | 25 | // Run the update 26 | db.connect() 27 | .then(updateProviderDescription) 28 | .then(() => { 29 | console.log('Done'); 30 | process.exit(0); 31 | }) 32 | .catch(err => { 33 | console.error('Error:', err); 34 | process.exit(1); 35 | }); 36 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webssh-control-server", 3 | "version": "1.0.0", 4 | "description": "IntelliSSH Backend Server", 5 | "main": "src/app.js", 6 | "scripts": { 7 | "start": "nodemon src/app.js", 8 | "dev": "nodemon src/app.js", 9 | "test": "jest" 10 | }, 11 | "keywords": [ 12 | "ssh", 13 | "webssh", 14 | "terminal", 15 | "web" 16 | ], 17 | "author": "", 18 | "license": "ISC", 19 | "dependencies": { 20 | "bcrypt": "^5.1.1", 21 | "cors": "^2.8.5", 22 | "crypto": "^1.0.1", 23 | "dotenv": "^16.3.1", 24 | "express": "^4.18.2", 25 | "express-rate-limit": "^6.8.1", 26 | "helmet": "^7.0.0", 27 | "jsonwebtoken": "^9.0.2", 28 | "multer": "^1.4.5-lts.1", 29 | "node-fetch": "^2.6.7", 30 | "node-pty": "^1.0.0", 31 | "nodemailer": "^7.0.3", 32 | "socket.io": "^4.7.2", 33 | "sqlite3": "^5.1.6", 34 | "ssh2": "^1.14.0", 35 | "uuid": "^9.0.1", 36 | "speakeasy": "^2.0.0" 37 | }, 38 | "devDependencies": { 39 | "jest": "^29.6.2", 40 | "nodemon": "^3.0.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /server/src/db/createCredentialsTableMigration.js: -------------------------------------------------------------------------------- 1 | const db = require('./database'); 2 | 3 | async function createCredentialsTable() { 4 | const createTableSql = ` 5 | CREATE TABLE IF NOT EXISTS credentials ( 6 | id INTEGER PRIMARY KEY AUTOINCREMENT, 7 | user_id INTEGER NOT NULL, 8 | name TEXT NOT NULL, 9 | type TEXT NOT NULL, -- 'password' or 'private_key' 10 | username TEXT, 11 | password TEXT, -- encrypted 12 | private_key TEXT, -- encrypted 13 | passphrase TEXT, -- encrypted 14 | iv TEXT, -- Initialization Vector for encryption 15 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 16 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 17 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, 18 | UNIQUE(user_id, name) 19 | ) 20 | `; 21 | 22 | try { 23 | await db.run(createTableSql); 24 | console.log('Credentials table created or already exists.'); 25 | } catch (error) { 26 | console.error('Error creating credentials table:', error.message); 27 | throw error; 28 | } 29 | } 30 | 31 | module.exports = { createCredentialsTable }; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Clusterzx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webssh-control-client", 3 | "version": "1.0.0", 4 | "description": "IntelliSSH Frontend", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore" 11 | }, 12 | "dependencies": { 13 | "@headlessui/vue": "^1.7.16", 14 | "@heroicons/vue": "^2.0.18", 15 | "axios": "^1.5.0", 16 | "html2canvas": "^1.4.1", 17 | "pinia": "^2.1.6", 18 | "qrcode.vue": "^3.4.1", 19 | "socket.io-client": "^4.7.2", 20 | "vue": "^3.3.4", 21 | "vue-i18n": "^9.14.5", 22 | "vue-router": "^4.2.4", 23 | "xterm": "^5.3.0", 24 | "xterm-addon-fit": "^0.8.0", 25 | "xterm-addon-web-links": "^0.9.0" 26 | }, 27 | "devDependencies": { 28 | "@intlify/unplugin-vue-i18n": "^6.0.8", 29 | "@vitejs/plugin-vue": "^4.3.4", 30 | "autoprefixer": "^10.4.15", 31 | "eslint": "^8.47.0", 32 | "eslint-plugin-vue": "^9.17.0", 33 | "postcss": "^8.4.28", 34 | "tailwindcss": "^3.3.3", 35 | "terser": "^5.30.3", 36 | "vite": "^4.4.9" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import { resolve } from 'path' 4 | import vueI18n from '@intlify/unplugin-vue-i18n/vite' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | vue(), 10 | ], 11 | resolve: { 12 | alias: { 13 | '@': resolve(__dirname, 'src'), 14 | }, 15 | }, 16 | server: { 17 | port: 8080, 18 | host: true, 19 | proxy: { 20 | '/api': { 21 | target: 'http://localhost:3000', 22 | changeOrigin: true, 23 | secure: false, 24 | }, 25 | '/socket.io': { 26 | target: 'http://localhost:3000', 27 | changeOrigin: true, 28 | ws: true, 29 | }, 30 | }, 31 | }, 32 | build: { 33 | outDir: 'dist', 34 | assetsDir: 'assets', 35 | sourcemap: false, 36 | minify: 'terser', 37 | rollupOptions: { 38 | output: { 39 | manualChunks: { 40 | vendor: ['vue', 'vue-router', 'pinia'], 41 | terminal: ['xterm', 'xterm-addon-fit', 'xterm-addon-web-links'], 42 | utils: ['axios', 'socket.io-client'] 43 | } 44 | } 45 | } 46 | }, 47 | }) 48 | -------------------------------------------------------------------------------- /server/src/db/userProfileMigration.js: -------------------------------------------------------------------------------- 1 | const db = require('./database'); 2 | 3 | async function runUserProfileMigration() { 4 | try { 5 | console.log('Starting user profile migration...'); 6 | 7 | // Check if email column exists in users table 8 | const usersTableInfo = await db.all("PRAGMA table_info(users)"); 9 | const emailColumnExists = usersTableInfo.some(column => column.name === 'email'); 10 | 11 | // Add email column if it doesn't exist 12 | if (!emailColumnExists) { 13 | console.log('Adding email column to users table...'); 14 | await db.run('ALTER TABLE users ADD COLUMN email TEXT'); 15 | console.log('Email column added successfully.'); 16 | } else { 17 | console.log('Email column already exists in users table. No migration needed.'); 18 | } 19 | 20 | console.log('User profile migration completed successfully.'); 21 | } catch (error) { 22 | console.error('User profile migration failed:', error); 23 | } 24 | } 25 | 26 | // Export for use in other files 27 | module.exports = { runUserProfileMigration }; 28 | 29 | // Run migration if this file is executed directly 30 | if (require.main === module) { 31 | db.connect() 32 | .then(runUserProfileMigration) 33 | .then(() => db.close()) 34 | .catch(err => { 35 | console.error('Migration error:', err); 36 | process.exit(1); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ---- Build client ---- 2 | FROM node:20 AS client-build 3 | WORKDIR /app/client 4 | COPY client/package.json client/package-lock.json ./ 5 | RUN npm ci 6 | COPY client/ . 7 | RUN npm run build 8 | 9 | # ---- Build server ---- 10 | FROM node:20 AS server-build 11 | WORKDIR /app/server 12 | COPY server/package.json server/package-lock.json ./ 13 | RUN npm ci 14 | COPY server/ . 15 | # (Optional: build step, falls z.B. TypeScript verwendet wird) 16 | 17 | # ---- Production image ---- 18 | FROM node:20-slim 19 | ENV NODE_ENV=production 20 | WORKDIR /app 21 | 22 | # Copy server code 23 | COPY --from=server-build /app/server /app/server 24 | 25 | # Copy client build to server's public directory (anpassen falls nötig) 26 | COPY --from=client-build /app/client/dist /app/server/public 27 | 28 | # Install production dependencies for server 29 | WORKDIR /app/server 30 | RUN apt-get update && apt-get install -y python3 make g++ && npm ci --omit=dev && apt-get remove -y python3 make g++ && apt-get autoremove -y && rm -rf /var/lib/apt/lists/* 31 | 32 | # Copy start script 33 | WORKDIR /app 34 | COPY start.sh . 35 | RUN npm install -g nodemon 36 | RUN npm install -g pm2 37 | RUN chmod +x start.sh 38 | 39 | # Expose ports (z.B. 3000 für Server, 5173 für Vite falls benötigt) 40 | EXPOSE 3000 41 | 42 | # Start both server and client (Client wird als statische Files vom Server ausgeliefert) 43 | CMD ["bash", "start.sh"] 44 | -------------------------------------------------------------------------------- /server/src/db/addTotpToUsersMigration.js: -------------------------------------------------------------------------------- 1 | const db = require('./database'); 2 | 3 | async function addTotpToUsersMigration() { 4 | try { 5 | const row = await db.get("SELECT name FROM sqlite_master WHERE type='table' AND name='users'"); 6 | 7 | if (row) { 8 | const columns = await db.all("PRAGMA table_info(users)"); 9 | const columnNames = columns.map(col => col.name); 10 | 11 | if (!columnNames.includes('totpSecret')) { 12 | await db.run(`ALTER TABLE users ADD COLUMN totpSecret TEXT`); 13 | console.log("Added totpSecret column to users table."); 14 | } else { 15 | console.log("totpSecret column already exists in users table."); 16 | } 17 | 18 | if (!columnNames.includes('is2faEnabled')) { 19 | await db.run(`ALTER TABLE users ADD COLUMN is2faEnabled INTEGER DEFAULT 0`); 20 | console.log("Added is2faEnabled column to users table."); 21 | } else { 22 | console.log("is2faEnabled column already exists in users table."); 23 | } 24 | console.log("TOTP columns migration check completed."); 25 | } else { 26 | console.log("Users table does not exist. Skipping totpSecret and is2faEnabled column migration."); 27 | } 28 | } catch (err) { 29 | console.error("Error in addTotpToUsersMigration:", err.message); 30 | throw err; // Re-throw to ensure it's caught by runMigration's catch block 31 | } 32 | } 33 | 34 | module.exports = addTotpToUsersMigration; -------------------------------------------------------------------------------- /docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | webssh-server: 5 | build: 6 | context: ./server 7 | dockerfile: ../docker/Dockerfile.server 8 | container_name: webssh-server 9 | ports: 10 | - "3000:3000" 11 | volumes: 12 | - webssh_data:/app/data 13 | environment: 14 | - NODE_ENV=production 15 | - PORT=3000 16 | - DB_PATH=/app/data/webssh.db 17 | - JWT_SECRET=${JWT_SECRET:-your_jwt_secret_change_in_production} 18 | - JWT_EXPIRES_IN=24h 19 | - ENCRYPTION_KEY=${ENCRYPTION_KEY:-your_32_byte_hex_encryption_key_change_in_production} 20 | - CORS_ORIGIN=http://localhost:8080 21 | - RATE_LIMIT_WINDOW_MS=900000 22 | - RATE_LIMIT_MAX_REQUESTS=100 23 | restart: unless-stopped 24 | networks: 25 | - webssh-network 26 | healthcheck: 27 | test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"] 28 | interval: 30s 29 | timeout: 10s 30 | retries: 3 31 | start_period: 40s 32 | 33 | webssh-client: 34 | build: 35 | context: ./client 36 | dockerfile: ../docker/Dockerfile.client 37 | container_name: webssh-client 38 | ports: 39 | - "8080:80" 40 | depends_on: 41 | webssh-server: 42 | condition: service_healthy 43 | restart: unless-stopped 44 | networks: 45 | - webssh-network 46 | healthcheck: 47 | test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/"] 48 | interval: 30s 49 | timeout: 10s 50 | retries: 3 51 | start_period: 40s 52 | 53 | volumes: 54 | webssh_data: 55 | driver: local 56 | 57 | networks: 58 | webssh-network: 59 | driver: bridge 60 | -------------------------------------------------------------------------------- /.github/workflows/docker-build-and-push.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [published] # Only triggers when a release is published 4 | schedule: 5 | - cron: "0 0 * * *" # Nightly build 6 | jobs: 7 | docker: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v3 12 | with: 13 | fetch-depth: 0 14 | 15 | - name: Set up QEMU 16 | uses: docker/setup-qemu-action@v2 17 | 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v2 20 | 21 | - name: Log in to Docker Hub 22 | uses: docker/login-action@v2 23 | with: 24 | username: ${{ secrets.DOCKER_USERNAME }} 25 | password: ${{ secrets.DOCKER_TOKEN }} 26 | 27 | - name: Login to GitHub Container Registry 28 | uses: docker/login-action@v2 29 | with: 30 | registry: ghcr.io 31 | username: ${{ github.repository_owner }} 32 | password: ${{ secrets.GHCR_PAT }} 33 | 34 | - name: Extract metadata 35 | id: meta 36 | uses: docker/metadata-action@v4 37 | with: 38 | images: | 39 | ${{ secrets.DOCKER_USERNAME }}/intellissh 40 | ghcr.io/${{ github.repository_owner }}/intellissh 41 | tags: | 42 | type=schedule,pattern=nightly 43 | type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }} 44 | type=raw,value=latest,enable=${{ github.event_name == 'release' }} 45 | 46 | - name: Build and push 47 | uses: docker/build-push-action@v4 48 | with: 49 | context: . 50 | push: true 51 | platforms: linux/amd64,linux/arm64 52 | tags: ${{ steps.meta.outputs.tags }} 53 | labels: ${{ steps.meta.outputs.labels }} 54 | -------------------------------------------------------------------------------- /.github/workflows/docker-build-and-publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [published] 4 | push: 5 | branches: 6 | - 'integration-dev' 7 | workflow_dispatch: 8 | 9 | jobs: 10 | docker: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up QEMU 19 | uses: docker/setup-qemu-action@v2 20 | 21 | - name: Set up Docker Buildx 22 | uses: docker/setup-buildx-action@v2 23 | 24 | - name: Log in to Docker Hub 25 | uses: docker/login-action@v2 26 | with: 27 | username: ${{ secrets.DOCKER_USERNAME }} 28 | password: ${{ secrets.DOCKER_TOKEN }} 29 | 30 | - name: Login to GitHub Container Registry 31 | uses: docker/login-action@v2 32 | with: 33 | registry: ghcr.io 34 | username: ${{ github.repository_owner }} 35 | password: ${{ secrets.GHCR_PAT }} 36 | 37 | - name: Extract metadata 38 | id: meta 39 | uses: docker/metadata-action@v4 40 | with: 41 | images: | 42 | ${{ secrets.DOCKER_USERNAME }}/intellissh 43 | ghcr.io/${{ github.repository_owner }}/intellissh 44 | tags: | 45 | type=ref,event=branch 46 | 47 | # 保留原有标签规则 48 | type=schedule,pattern=nightly 49 | type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }} 50 | type=raw,value=latest,enable=${{ github.event_name == 'release' }} 51 | 52 | - name: Build and push 53 | uses: docker/build-push-action@v4 54 | with: 55 | context: . 56 | push: true 57 | platforms: linux/amd64,linux/arm64 58 | tags: ${{ steps.meta.outputs.tags }} 59 | labels: ${{ steps.meta.outputs.labels }} 60 | -------------------------------------------------------------------------------- /server/src/db/testCustomApi.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Test script for custom API settings 3 | * This script will: 4 | * 1. Connect to the database 5 | * 2. Run the custom API settings migration 6 | * 3. Verify that the settings exist 7 | */ 8 | 9 | const db = require('./database'); 10 | const { addCustomApiSettings } = require('./customApiMigration'); 11 | 12 | async function testCustomApiSettings() { 13 | try { 14 | console.log('Starting custom API settings test'); 15 | 16 | // Run the migration 17 | await addCustomApiSettings(); 18 | 19 | // Verify the settings exist 20 | const customApiUrl = await db.get('SELECT * FROM settings WHERE id = ?', ['custom_api_url']); 21 | const customApiKey = await db.get('SELECT * FROM settings WHERE id = ?', ['custom_api_key']); 22 | const customModel = await db.get('SELECT * FROM settings WHERE id = ?', ['custom_model']); 23 | 24 | console.log('Custom API URL setting:', customApiUrl ? 'Found' : 'Not found'); 25 | console.log('Custom API Key setting:', customApiKey ? 'Found' : 'Not found'); 26 | console.log('Custom Model setting:', customModel ? 'Found' : 'Not found'); 27 | 28 | // Verify llm_provider has custom as a valid option 29 | const llmProvider = await db.get('SELECT * FROM settings WHERE id = ?', ['llm_provider']); 30 | console.log('LLM Provider description:', llmProvider.description); 31 | 32 | console.log('Test completed successfully'); 33 | return true; 34 | } catch (error) { 35 | console.error('Test failed:', error); 36 | throw error; 37 | } 38 | } 39 | 40 | // Run test if this file is executed directly 41 | if (require.main === module) { 42 | db.connect() 43 | .then(testCustomApiSettings) 44 | .then(() => { 45 | console.log('Test completed'); 46 | process.exit(0); 47 | }) 48 | .catch(err => { 49 | console.error('Test error:', err); 50 | process.exit(1); 51 | }); 52 | } 53 | 54 | module.exports = { testCustomApiSettings }; 55 | -------------------------------------------------------------------------------- /server/src/db/passwordResetMigration.js: -------------------------------------------------------------------------------- 1 | const db = require('./database'); 2 | 3 | async function runPasswordResetMigration() { 4 | try { 5 | console.log('Starting password reset migration...'); 6 | 7 | // Check if reset_token column exists in users table 8 | const usersTableInfo = await db.all("PRAGMA table_info(users)"); 9 | const resetTokenExists = usersTableInfo.some(column => column.name === 'reset_token'); 10 | const resetTokenExpiresExists = usersTableInfo.some(column => column.name === 'reset_token_expires'); 11 | 12 | // Add reset_token column if it doesn't exist 13 | if (!resetTokenExists) { 14 | console.log('Adding reset_token column to users table...'); 15 | await db.run('ALTER TABLE users ADD COLUMN reset_token TEXT'); 16 | console.log('Reset token column added successfully.'); 17 | } else { 18 | console.log('Reset token column already exists in users table. No migration needed.'); 19 | } 20 | 21 | // Add reset_token_expires column if it doesn't exist 22 | if (!resetTokenExpiresExists) { 23 | console.log('Adding reset_token_expires column to users table...'); 24 | await db.run('ALTER TABLE users ADD COLUMN reset_token_expires DATETIME'); 25 | console.log('Reset token expires column added successfully.'); 26 | } else { 27 | console.log('Reset token expires column already exists in users table. No migration needed.'); 28 | } 29 | 30 | console.log('Password reset migration completed successfully.'); 31 | } catch (error) { 32 | console.error('Password reset migration failed:', error); 33 | } 34 | } 35 | 36 | // Export for use in other files 37 | module.exports = { runPasswordResetMigration }; 38 | 39 | // Run migration if this file is executed directly 40 | if (require.main === module) { 41 | db.connect() 42 | .then(runPasswordResetMigration) 43 | .then(() => db.close()) 44 | .catch(err => { 45 | console.error('Migration error:', err); 46 | process.exit(1); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /server/src/db/emailSettingsMigration.js: -------------------------------------------------------------------------------- 1 | const db = require('./database'); 2 | 3 | async function runEmailSettingsMigration() { 4 | try { 5 | console.log('Starting email settings migration...'); 6 | 7 | // Email settings to add 8 | const emailSettings = [ 9 | { id: 'smtp_host', name: 'SMTP Host', value: '', category: 'email', description: 'SMTP server hostname', is_sensitive: 0 }, 10 | { id: 'smtp_port', name: 'SMTP Port', value: '587', category: 'email', description: 'SMTP server port', is_sensitive: 0 }, 11 | { id: 'smtp_user', name: 'SMTP Username', value: '', category: 'email', description: 'SMTP server username', is_sensitive: 0 }, 12 | { id: 'smtp_password', name: 'SMTP Password', value: '', category: 'email', description: 'SMTP server password', is_sensitive: 1 }, 13 | { id: 'email_from', name: 'From Email', value: 'noreply@webssh.example.com', category: 'email', description: 'Email address used as sender', is_sensitive: 0 } 14 | ]; 15 | 16 | // Check if settings already exist 17 | for (const setting of emailSettings) { 18 | const existing = await db.get('SELECT id FROM settings WHERE id = ?', [setting.id]); 19 | 20 | if (!existing) { 21 | console.log(`Adding setting: ${setting.id}`); 22 | await db.run( 23 | 'INSERT INTO settings (id, name, value, category, description, is_sensitive) VALUES (?, ?, ?, ?, ?, ?)', 24 | [setting.id, setting.name, setting.value, setting.category, setting.description, setting.is_sensitive] 25 | ); 26 | } else { 27 | console.log(`Setting ${setting.id} already exists.`); 28 | } 29 | } 30 | 31 | console.log('Email settings migration completed successfully.'); 32 | } catch (error) { 33 | console.error('Email settings migration failed:', error); 34 | } 35 | } 36 | 37 | // Run migration if this file is executed directly 38 | if (require.main === module) { 39 | db.connect() 40 | .then(runEmailSettingsMigration) 41 | .then(() => db.close()) 42 | .catch(err => { 43 | console.error('Migration error:', err); 44 | process.exit(1); 45 | }); 46 | } 47 | 48 | module.exports = { runEmailSettingsMigration }; 49 | -------------------------------------------------------------------------------- /server/src/db/customApiMigration.js: -------------------------------------------------------------------------------- 1 | const db = require('./database'); 2 | 3 | async function addCustomApiSettings() { 4 | try { 5 | console.log('Starting Custom API settings migration'); 6 | 7 | // Check if custom API settings already exist 8 | const customApiUrlSetting = await db.get('SELECT id FROM settings WHERE id = ?', ['custom_api_url']); 9 | const customApiKeySetting = await db.get('SELECT id FROM settings WHERE id = ?', ['custom_api_key']); 10 | const customModelSetting = await db.get('SELECT id FROM settings WHERE id = ?', ['custom_model']); 11 | 12 | // Insert settings that don't exist 13 | if (!customApiUrlSetting) { 14 | console.log('Adding custom_api_url setting'); 15 | await db.run( 16 | 'INSERT INTO settings (id, name, value, category, description, is_sensitive) VALUES (?, ?, ?, ?, ?, ?)', 17 | ['custom_api_url', 'Custom API URL', '', 'llm', 'Base URL for custom OpenAI-compatible API', 0] 18 | ); 19 | } else { 20 | console.log('custom_api_url setting already exists'); 21 | } 22 | 23 | if (!customApiKeySetting) { 24 | console.log('Adding custom_api_key setting'); 25 | await db.run( 26 | 'INSERT INTO settings (id, name, value, category, description, is_sensitive) VALUES (?, ?, ?, ?, ?, ?)', 27 | ['custom_api_key', 'Custom API Key', '', 'llm', 'API key for custom OpenAI-compatible API', 1] 28 | ); 29 | } else { 30 | console.log('custom_api_key setting already exists'); 31 | } 32 | 33 | if (!customModelSetting) { 34 | console.log('Adding custom_model setting'); 35 | await db.run( 36 | 'INSERT INTO settings (id, name, value, category, description, is_sensitive) VALUES (?, ?, ?, ?, ?, ?)', 37 | ['custom_model', 'Custom Model', 'gpt-3.5-turbo', 'llm', 'Model name for custom API', 0] 38 | ); 39 | } else { 40 | console.log('custom_model setting already exists'); 41 | } 42 | 43 | console.log('Custom API settings migration completed successfully'); 44 | return true; 45 | } catch (error) { 46 | console.error('Custom API settings migration failed:', error); 47 | throw error; 48 | } 49 | } 50 | 51 | // Run migration if this file is executed directly 52 | if (require.main === module) { 53 | db.connect() 54 | .then(addCustomApiSettings) 55 | .then(() => { 56 | console.log('Migration completed'); 57 | process.exit(0); 58 | }) 59 | .catch(err => { 60 | console.error('Migration error:', err); 61 | process.exit(1); 62 | }); 63 | } 64 | 65 | module.exports = { addCustomApiSettings }; 66 | -------------------------------------------------------------------------------- /client/src/components/LanguageSwitcher.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 89 | -------------------------------------------------------------------------------- /server/src/api/debug.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { Client } = require('ssh2'); 4 | const { authenticateToken } = require('../middleware/authMiddleware'); 5 | 6 | // Protect all routes with authentication 7 | router.use(authenticateToken); 8 | 9 | /** 10 | * Debug SSH connection test 11 | * Tests SSH connection with private key and optional passphrase 12 | * POST /api/ssh/debug-test 13 | */ 14 | router.post('/debug-test', async (req, res) => { 15 | const { hostname, port, username, privateKey, keyPassphrase } = req.body; 16 | 17 | if (!hostname || !username || !privateKey) { 18 | return res.status(400).json({ 19 | success: false, 20 | message: 'Hostname, username, and private key are required' 21 | }); 22 | } 23 | 24 | try { 25 | const result = await testSshConnection(hostname, port, username, privateKey, keyPassphrase); 26 | return res.json(result); 27 | } catch (error) { 28 | console.error('SSH debug test error:', error.message); 29 | return res.status(500).json({ 30 | success: false, 31 | message: error.message || 'SSH connection failed', 32 | details: error.stack 33 | }); 34 | } 35 | }); 36 | 37 | /** 38 | * Test SSH connection with private key 39 | */ 40 | async function testSshConnection(hostname, port, username, privateKey, keyPassphrase) { 41 | return new Promise((resolve, reject) => { 42 | const conn = new Client(); 43 | let connectionTimeout = setTimeout(() => { 44 | conn.end(); 45 | reject(new Error('Connection timeout after 15 seconds')); 46 | }, 15000); 47 | 48 | conn.on('ready', () => { 49 | clearTimeout(connectionTimeout); 50 | conn.end(); 51 | resolve({ 52 | success: true, 53 | message: `Successfully connected to ${username}@${hostname}:${port}` 54 | }); 55 | }); 56 | 57 | conn.on('error', (err) => { 58 | clearTimeout(connectionTimeout); 59 | conn.end(); 60 | reject(err); 61 | }); 62 | 63 | // Prepare connection options 64 | const connectOptions = { 65 | host: hostname, 66 | port: port || 22, 67 | username: username, 68 | readyTimeout: 10000 69 | }; 70 | 71 | // Add private key with optional passphrase 72 | connectOptions.privateKey = privateKey; 73 | if (keyPassphrase) { 74 | connectOptions.passphrase = keyPassphrase; 75 | } 76 | 77 | // Attempt connection 78 | console.log(`Connecting to ${username}@${hostname}:${port || 22}...`); 79 | console.log('Using private key:', privateKey ? 'Yes' : 'No'); 80 | console.log('Using passphrase:', keyPassphrase ? 'Yes' : 'No'); 81 | console.log('Connection options:', connectOptions); 82 | conn.connect(connectOptions); 83 | }); 84 | } 85 | 86 | module.exports = router; 87 | -------------------------------------------------------------------------------- /client/src/components/DarkModeToggle.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 93 | -------------------------------------------------------------------------------- /client/src/stores/credentialStore.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import axios from 'axios'; 3 | 4 | const API_URL = import.meta.env.VITE_API_URL || '/api'; 5 | 6 | export const useCredentialStore = defineStore('credential', { 7 | state: () => ({ 8 | credentials: [], 9 | loading: false, 10 | error: null, 11 | }), 12 | actions: { 13 | async fetchCredentials() { 14 | this.loading = true; 15 | this.error = null; 16 | try { 17 | const response = await axios.get(`${API_URL}/credentials`, { 18 | headers: { 19 | Authorization: `Bearer ${localStorage.getItem('token')}`, 20 | }, 21 | }); 22 | this.credentials = response.data; 23 | } catch (err) { 24 | this.error = err.response?.data?.message || err.message; 25 | console.error('Error fetching credentials:', err); 26 | } finally { 27 | this.loading = false; 28 | } 29 | }, 30 | async createCredential(credentialData) { 31 | this.loading = true; 32 | this.error = null; 33 | try { 34 | const response = await axios.post(`${API_URL}/credentials`, credentialData, { 35 | headers: { 36 | Authorization: `Bearer ${localStorage.getItem('token')}`, 37 | }, 38 | }); 39 | this.credentials.push(response.data); 40 | return { success: true, credential: response.data }; 41 | } catch (err) { 42 | this.error = err.response?.data?.message || err.message; 43 | console.error('Error creating credential:', err); 44 | return { success: false, error: this.error }; 45 | } finally { 46 | this.loading = false; 47 | } 48 | }, 49 | async updateCredential(id, credentialData) { 50 | this.loading = true; 51 | this.error = null; 52 | try { 53 | const response = await axios.put(`${API_URL}/credentials/${id}`, credentialData, { 54 | headers: { 55 | Authorization: `Bearer ${localStorage.getItem('token')}`, 56 | }, 57 | }); 58 | const index = this.credentials.findIndex((cred) => cred.id === id); 59 | if (index !== -1) { 60 | this.credentials[index] = { ...this.credentials[index], ...response.data }; 61 | } 62 | return { success: true, credential: response.data }; 63 | } catch (err) { 64 | this.error = err.response?.data?.message || err.message; 65 | console.error('Error updating credential:', err); 66 | return { success: false, error: this.error }; 67 | } finally { 68 | this.loading = false; 69 | } 70 | }, 71 | async deleteCredential(id) { 72 | this.loading = true; 73 | this.error = null; 74 | try { 75 | await axios.delete(`${API_URL}/credentials/${id}`, { 76 | headers: { 77 | Authorization: `Bearer ${localStorage.getItem('token')}`, 78 | }, 79 | }); 80 | this.credentials = this.credentials.filter((cred) => cred.id !== id); 81 | } catch (err) { 82 | this.error = err.response?.data?.message || err.message; 83 | console.error('Error deleting credential:', err); 84 | throw err; 85 | } finally { 86 | this.loading = false; 87 | } 88 | }, 89 | }, 90 | }); 91 | -------------------------------------------------------------------------------- /server/src/api/credentials.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { authenticateToken } = require('../middleware/authMiddleware'); 4 | const credentialService = require('../services/credentialService'); 5 | 6 | // Create a new credential 7 | router.post('/', authenticateToken, async (req, res) => { 8 | const { name, type, username, password, privateKey, passphrase } = req.body; 9 | const userId = req.user.id; 10 | 11 | if (!name || !type) { 12 | return res.status(400).json({ message: 'Name and type are required.' }); 13 | } 14 | 15 | if (type === credentialService.CREDENTIAL_TYPES.PASSWORD && !password) { 16 | return res.status(400).json({ message: 'Password is required for password type credentials.' }); 17 | } 18 | 19 | if (type === credentialService.CREDENTIAL_TYPES.PRIVATE_KEY && !privateKey) { 20 | return res.status(400).json({ message: 'Private key is required for private key type credentials.' }); 21 | } 22 | 23 | try { 24 | const credential = await credentialService.createCredential( 25 | userId, name, type, username, password, privateKey, passphrase 26 | ); 27 | res.status(201).json(credential); 28 | } catch (error) { 29 | res.status(500).json({ message: error.message }); 30 | } 31 | }); 32 | 33 | // Get all credentials for the authenticated user 34 | router.get('/', authenticateToken, async (req, res) => { 35 | const userId = req.user.id; 36 | try { 37 | const credentials = await credentialService.getCredentialsByUserId(userId); 38 | res.json(credentials); 39 | } catch (error) { 40 | res.status(500).json({ message: error.message }); 41 | } 42 | }); 43 | 44 | // Get a single credential by ID (for editing/viewing details) 45 | router.get('/:id', authenticateToken, async (req, res) => { 46 | const { id } = req.params; 47 | const userId = req.user.id; 48 | try { 49 | const credential = await credentialService.getCredentialById(id, userId); 50 | if (!credential) { 51 | return res.status(404).json({ message: 'Credential not found.' }); 52 | } 53 | res.json(credential); 54 | } catch (error) { 55 | res.status(500).json({ message: error.message }); 56 | } 57 | }); 58 | 59 | // Update a credential 60 | router.put('/:id', authenticateToken, async (req, res) => { 61 | const { id } = req.params; 62 | const { name, type, username, password, privateKey, passphrase } = req.body; 63 | const userId = req.user.id; 64 | 65 | if (!name || !type) { 66 | return res.status(400).json({ message: 'Name and type are required.' }); 67 | } 68 | 69 | try { 70 | const updatedCredential = await credentialService.updateCredential( 71 | id, userId, name, type, username, password, privateKey, passphrase 72 | ); 73 | res.json(updatedCredential); 74 | } catch (error) { 75 | res.status(500).json({ message: error.message }); 76 | } 77 | }); 78 | 79 | // Delete a credential 80 | router.delete('/:id', authenticateToken, async (req, res) => { 81 | const { id } = req.params; 82 | const userId = req.user.id; 83 | try { 84 | await credentialService.deleteCredential(id, userId); 85 | res.status(204).send(); // No content on successful deletion 86 | } catch (error) { 87 | res.status(500).json({ message: error.message }); 88 | } 89 | }); 90 | 91 | module.exports = router; 92 | -------------------------------------------------------------------------------- /client/src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import { useAuthStore } from '@/stores/authStore' 3 | 4 | // Views 5 | import HomeView from '@/views/HomeView.vue' 6 | import LoginView from '@/views/LoginView.vue' 7 | import TerminalView from '@/views/TerminalView.vue' 8 | import SshDebugView from '@/views/SshDebugView.vue' 9 | import SettingsView from '@/views/SettingsView.vue' 10 | import ProfileView from '@/views/ProfileView.vue' 11 | import ForgotPasswordView from '@/views/ForgotPasswordView.vue' 12 | import ResetPasswordView from '@/views/ResetPasswordView.vue' 13 | import CredentialsView from '@/views/CredentialsView.vue' 14 | 15 | const routes = [ 16 | { 17 | path: '/', 18 | name: 'home', 19 | component: HomeView, 20 | meta: { requiresAuth: true } 21 | }, 22 | { 23 | path: '/login', 24 | name: 'login', 25 | component: LoginView, 26 | meta: { requiresAuth: false } 27 | }, 28 | { 29 | path: '/terminal/:sessionId', 30 | name: 'terminal', 31 | component: TerminalView, 32 | meta: { requiresAuth: true }, 33 | props: true 34 | }, 35 | { 36 | path: '/terminal', 37 | name: 'terminal-new', 38 | component: TerminalView, 39 | meta: { requiresAuth: true } 40 | }, 41 | { 42 | path: '/debug', 43 | name: 'ssh-debug', 44 | component: SshDebugView, 45 | meta: { requiresAuth: true } 46 | }, 47 | { 48 | path: '/settings', 49 | name: 'settings', 50 | component: SettingsView, 51 | meta: { requiresAuth: true } 52 | }, 53 | { 54 | path: '/profile', 55 | name: 'profile', 56 | component: ProfileView, 57 | meta: { requiresAuth: true } 58 | }, 59 | { 60 | path: '/forgot-password', 61 | name: 'forgot-password', 62 | component: ForgotPasswordView, 63 | meta: { requiresAuth: false } 64 | }, 65 | { 66 | path: '/reset-password/:token', 67 | name: 'reset-password', 68 | component: ResetPasswordView, 69 | meta: { requiresAuth: false }, 70 | props: true 71 | }, 72 | { 73 | path: '/credentials', 74 | name: 'credentials', 75 | component: CredentialsView, 76 | meta: { requiresAuth: true } 77 | }, 78 | // Catch all redirect to home 79 | { 80 | path: '/:pathMatch(.*)*', 81 | redirect: '/' 82 | } 83 | ] 84 | 85 | const router = createRouter({ 86 | history: createWebHistory(), 87 | routes 88 | }) 89 | 90 | // Navigation guards 91 | router.beforeEach(async (to, from, next) => { 92 | const authStore = useAuthStore() 93 | 94 | // Initialize auth store if not already done 95 | if (!authStore.user && authStore.token) { 96 | await authStore.init() 97 | } 98 | 99 | const requiresAuth = to.matched.some(record => record.meta.requiresAuth) 100 | const isAuthenticated = authStore.isAuthenticated 101 | 102 | if (requiresAuth && !isAuthenticated) { 103 | // Redirect to login if route requires auth and user is not authenticated 104 | next({ 105 | name: 'login', 106 | query: { redirect: to.fullPath } 107 | }) 108 | } else if (!requiresAuth && isAuthenticated && to.name === 'login') { 109 | // Redirect to home if user is authenticated and trying to access login 110 | next({ name: 'home' }) 111 | } else { 112 | // Proceed with navigation 113 | next() 114 | } 115 | }) 116 | 117 | export default router 118 | -------------------------------------------------------------------------------- /docs/LLM-HELPER.md: -------------------------------------------------------------------------------- 1 | # LLM Terminal Assistant 2 | 3 | The LLM Terminal Assistant is a feature that provides AI-powered assistance for terminal sessions in IntelliSSH. It can analyze terminal output, suggest commands, and help troubleshoot issues in real-time. 4 | 5 | ## Features 6 | 7 | - **Auto Command Suggestions**: The assistant can suggest commands based on terminal output 8 | - **Manual Prompting**: Ask questions or request specific help directly 9 | - **Command Execution with Approval**: Only execute commands after user review and approval 10 | - **Structured Outputs**: Uses OpenAI function calling and JSON schemas for reliable, consistent responses 11 | - **Support for Multiple LLM Providers**: Use either OpenAI API or Ollama 12 | - **Configurable**: Easily toggle the assistant on/off or change settings 13 | 14 | ## Usage 15 | 16 | ### Enabling the Assistant 17 | 18 | 1. Connect to an SSH session in IntelliSSH 19 | 2. The LLM Terminal Assistant panel will appear on the right side 20 | 3. Toggle the switch to enable the assistant 21 | 22 | ### Manual Prompts 23 | 24 | You can ask the assistant questions or request specific help: 25 | 26 | 1. Type your question in the text input at the bottom of the assistant panel 27 | 2. Click "Send" to submit your question 28 | 3. The assistant will respond and may suggest commands 29 | 30 | ### Command Execution with Approval 31 | 32 | When the assistant suggests a command: 33 | 34 | 1. The command will be displayed with an explanation 35 | 2. You'll see "Approve" and "Reject" buttons for the suggested command 36 | 3. If you click "Execute Command", the command will be run in the terminal 37 | 4. If you click "Reject", the command will be discarded 38 | 5. All suggestions, approvals, and rejections are logged in the assistant's history 39 | 40 | This approval workflow ensures that you always have control over what commands are executed, while still benefiting from the assistant's suggestions. 41 | 42 | ### Troubleshooting 43 | 44 | If you encounter issues: 45 | 46 | - Check that your API key is correct (for OpenAI) 47 | - Verify that Ollama is running (for Ollama) 48 | - Check the server logs for any errors 49 | - Try disabling and re-enabling the assistant 50 | 51 | ## Models 52 | 53 | ### OpenAI 54 | 55 | - Requires an API key 56 | - More accurate but requires internet connection and has usage costs 57 | 58 | ### Ollama 59 | 60 | - Runs locally, no API key required 61 | - Supported models: llama2, mistral, orca-mini 62 | - Free to use but may be less accurate than OpenAI models 63 | - Requires more system resources 64 | 65 | ## Security Considerations 66 | 67 | - The terminal output is sent to the LLM for processing 68 | - If using OpenAI, data leaves your system - consider privacy implications 69 | - With Ollama, everything stays local but requires more computing resources 70 | - The assistant will never execute destructive commands without explicit permission 71 | - Always review suggested commands before they're executed 72 | 73 | ## Examples 74 | 75 | ### Getting Help with a Command 76 | 77 | If you're unsure how to use a command, ask the assistant: 78 | 79 | ``` 80 | How do I search for files containing specific text? 81 | ``` 82 | 83 | ### Troubleshooting Errors 84 | 85 | If you encounter an error, the assistant can help explain it: 86 | 87 | ``` 88 | Why am I getting "permission denied" when trying to access this file? 89 | ``` 90 | 91 | ### Learning about System Status 92 | 93 | The assistant can help interpret system information: 94 | 95 | ``` 96 | What does this CPU usage information mean? 97 | -------------------------------------------------------------------------------- /server/src/services/encryptionService.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const settingsService = require('./settingsService'); 3 | 4 | class EncryptionService { 5 | constructor() { 6 | this.algorithm = 'aes-256-cbc'; 7 | this.key = null; 8 | this.initialized = false; 9 | } 10 | 11 | async init() { 12 | try { 13 | if (this.initialized && this.key) return; 14 | 15 | await this.loadEncryptionKey(); 16 | this.initialized = true; 17 | } catch (error) { 18 | console.error('Failed to initialize Encryption Service:', error); 19 | // Fall back to environment variable or generate a random key 20 | this.key = this.getFallbackEncryptionKey(); 21 | // Mark as initialized even when falling back so we don't retry on every call 22 | this.initialized = true; 23 | } 24 | } 25 | 26 | async loadEncryptionKey() { 27 | try { 28 | // Get encryption key from database settings 29 | const keyFromSettings = await settingsService.getSettingValue('encryption_key'); 30 | 31 | if (!keyFromSettings) { 32 | throw new Error('No encryption key found in settings'); 33 | } 34 | 35 | // Convert hex string to buffer 36 | if (keyFromSettings.length === 64) { 37 | this.key = Buffer.from(keyFromSettings, 'hex'); 38 | } else { 39 | // If not hex, use SHA-256 hash of the string 40 | this.key = crypto.createHash('sha256').update(keyFromSettings).digest(); 41 | } 42 | } catch (error) { 43 | console.error('Error loading encryption key from settings:', error); 44 | throw error; 45 | } 46 | } 47 | 48 | getFallbackEncryptionKey() { 49 | // Try to get from environment variable 50 | const keyFromEnv = process.env.ENCRYPTION_KEY; 51 | 52 | if (!keyFromEnv) { 53 | // Generate a random key for development (not recommended for production) 54 | console.warn('No encryption key found in settings or environment. Generating random key for development.'); 55 | return crypto.randomBytes(32); 56 | } 57 | 58 | // Convert hex string to buffer 59 | if (keyFromEnv.length === 64) { 60 | return Buffer.from(keyFromEnv, 'hex'); 61 | } 62 | 63 | // If not hex, use SHA-256 hash of the string 64 | return crypto.createHash('sha256').update(keyFromEnv).digest(); 65 | } 66 | 67 | encrypt(text) { 68 | if (!text) return { encryptedData: null, iv: null }; 69 | 70 | const iv = crypto.randomBytes(16); 71 | const cipher = crypto.createCipheriv(this.algorithm, this.key, iv); 72 | 73 | let encrypted = cipher.update(text, 'utf8', 'hex'); 74 | encrypted += cipher.final('hex'); 75 | 76 | return { 77 | iv: iv.toString('hex'), 78 | encryptedData: encrypted 79 | }; 80 | } 81 | 82 | decrypt(encryptedData, iv) { 83 | if (!encryptedData || !iv) return null; 84 | 85 | try { 86 | const decipher = crypto.createDecipheriv( 87 | this.algorithm, 88 | this.key, 89 | Buffer.from(iv, 'hex') 90 | ); 91 | 92 | let decrypted = decipher.update(encryptedData, 'hex', 'utf8'); 93 | decrypted += decipher.final('utf8'); 94 | 95 | return decrypted; 96 | } catch (error) { 97 | console.error('Decryption error:', error.message); 98 | return null; 99 | } 100 | } 101 | 102 | // Generate a secure random encryption key 103 | static generateKey() { 104 | return crypto.randomBytes(32).toString('hex'); 105 | } 106 | } 107 | 108 | module.exports = new EncryptionService(); 109 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{vue,js,ts,jsx,tsx}", 6 | ], 7 | darkMode: 'class', // Enable dark mode with class strategy 8 | theme: { 9 | extend: { 10 | colors: { 11 | terminal: { 12 | bg: '#1e1e1e', 13 | bgDark: '#121212', 14 | text: '#f0f0f0', 15 | cursor: '#ffffff', 16 | selection: '#264f78', 17 | accent: '#6366f1' 18 | }, 19 | primary: { 20 | 50: '#eff6ff', 21 | 100: '#dbeafe', 22 | 200: '#bfdbfe', 23 | 300: '#93c5fd', 24 | 400: '#60a5fa', 25 | 500: '#3b82f6', 26 | 600: '#2563eb', 27 | 700: '#1d4ed8', 28 | 800: '#1e40af', 29 | 900: '#1e3a8a', 30 | 950: '#172554', 31 | }, 32 | gray: { 33 | 50: '#f9fafb', 34 | 100: '#f3f4f6', 35 | 200: '#e5e7eb', 36 | 300: '#d1d5db', 37 | 400: '#9ca3af', 38 | 500: '#6b7280', 39 | 600: '#4b5563', 40 | 700: '#374151', 41 | 800: '#1f2937', 42 | 900: '#111827', 43 | 950: '#030712', 44 | }, 45 | indigo: { 46 | 50: '#eef2ff', 47 | 100: '#e0e7ff', 48 | 200: '#c7d2fe', 49 | 300: '#a5b4fc', 50 | 400: '#818cf8', 51 | 500: '#6366f1', 52 | 600: '#4f46e5', 53 | 700: '#4338ca', 54 | 800: '#3730a3', 55 | 900: '#312e81', 56 | 950: '#1e1b4b', 57 | }, 58 | slate: { 59 | 50: '#f8fafc', 60 | 100: '#f1f5f9', 61 | 200: '#e2e8f0', 62 | 300: '#cbd5e1', 63 | 400: '#94a3b8', 64 | 500: '#64748b', 65 | 600: '#475569', 66 | 700: '#334155', 67 | 800: '#1e293b', 68 | 900: '#0f172a', 69 | 950: '#020617', 70 | } 71 | }, 72 | fontFamily: { 73 | sans: ['Inter', 'ui-sans-serif', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'], 74 | mono: ['JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', 'monospace'], 75 | }, 76 | animation: { 77 | 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', 78 | 'bounce-slow': 'bounce 2s infinite', 79 | 'fade-in': 'fadeIn 0.3s ease-in-out', 80 | 'slide-in': 'slideIn 0.3s ease-out', 81 | }, 82 | keyframes: { 83 | fadeIn: { 84 | '0%': { opacity: '0' }, 85 | '100%': { opacity: '1' }, 86 | }, 87 | slideIn: { 88 | '0%': { transform: 'translateY(10px)', opacity: '0' }, 89 | '100%': { transform: 'translateY(0)', opacity: '1' }, 90 | }, 91 | }, 92 | boxShadow: { 93 | 'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)', 94 | 'soft-lg': '0 10px 25px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.025)', 95 | 'inner-soft': 'inset 0 2px 4px 0 rgba(0, 0, 0, 0.025)', 96 | }, 97 | borderRadius: { 98 | 'xl': '0.875rem', 99 | '2xl': '1rem', 100 | '3xl': '1.5rem', 101 | }, 102 | }, 103 | }, 104 | plugins: [ 105 | function({ addBase }) { 106 | addBase({ 107 | '@font-face': { 108 | fontFamily: 'Inter', 109 | fontWeight: '100 900', 110 | fontStyle: 'normal', 111 | fontDisplay: 'swap', 112 | src: 'url("https://rsms.me/inter/font-files/Inter-roman.var.woff2") format("woff2")' 113 | }, 114 | '@font-face': { 115 | fontFamily: 'JetBrains Mono', 116 | fontWeight: '100 900', 117 | fontStyle: 'normal', 118 | fontDisplay: 'swap', 119 | src: 'url("https://cdn.jsdelivr.net/gh/JetBrains/JetBrainsMono/web/woff2/JetBrainsMono-Regular.woff2") format("woff2")' 120 | } 121 | }); 122 | } 123 | ], 124 | } 125 | -------------------------------------------------------------------------------- /client/src/views/CredentialsView.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 96 | 97 | 102 | -------------------------------------------------------------------------------- /server/src/middleware/authMiddleware.js: -------------------------------------------------------------------------------- 1 | const authService = require('../services/authService'); 2 | 3 | // Check if user has admin role 4 | const isAdmin = (req, res, next) => { 5 | try { 6 | // Check if user exists and has an admin role 7 | if (!req.user) { 8 | return res.status(401).json({ 9 | error: 'Authentication required.' 10 | }); 11 | } 12 | 13 | // Check if user has admin role 14 | if (req.user.role !== 'admin') { 15 | console.log('Admin access denied to user:', req.user.username); 16 | return res.status(403).json({ 17 | error: 'Admin access required.' 18 | }); 19 | } 20 | 21 | console.log('Admin access granted to user:', req.user.username); 22 | next(); 23 | } catch (error) { 24 | console.error('Admin authorization error:', error.message); 25 | return res.status(500).json({ 26 | error: 'Internal server error during authorization.' 27 | }); 28 | } 29 | }; 30 | 31 | const authenticateToken = async (req, res, next) => { 32 | try { 33 | const authHeader = req.headers.authorization; 34 | 35 | if (!authHeader) { 36 | return res.status(401).json({ 37 | error: 'Access denied. No token provided.' 38 | }); 39 | } 40 | 41 | const token = authService.extractTokenFromHeader(authHeader); 42 | const decoded = authService.verifyToken(token); 43 | 44 | // Add user info to request 45 | req.user = decoded; 46 | next(); 47 | } catch (error) { 48 | console.error('Authentication error:', error.message); 49 | return res.status(401).json({ 50 | error: 'Invalid or expired token.' 51 | }); 52 | } 53 | }; 54 | 55 | const optionalAuth = async (req, res, next) => { 56 | try { 57 | const authHeader = req.headers.authorization; 58 | 59 | if (authHeader) { 60 | const token = authService.extractTokenFromHeader(authHeader); 61 | const decoded = authService.verifyToken(token); 62 | req.user = decoded; 63 | } 64 | 65 | next(); 66 | } catch (error) { 67 | // Continue without authentication if token is invalid 68 | next(); 69 | } 70 | }; 71 | 72 | // Middleware to validate user ownership of resources 73 | const validateResourceOwnership = (resourceIdParam = 'id') => { 74 | return async (req, res, next) => { 75 | try { 76 | const userId = req.user.id; 77 | const resourceUserId = req.body.user_id || req.params.user_id; 78 | 79 | // If resource has user_id, validate ownership 80 | if (resourceUserId && parseInt(resourceUserId) !== parseInt(userId)) { 81 | return res.status(403).json({ 82 | error: 'Access denied. You can only access your own resources.' 83 | }); 84 | } 85 | 86 | next(); 87 | } catch (error) { 88 | console.error('Resource ownership validation error:', error.message); 89 | return res.status(500).json({ 90 | error: 'Internal server error during authorization.' 91 | }); 92 | } 93 | }; 94 | }; 95 | 96 | // Middleware to add user ID to request body for creation operations 97 | const addUserToBody = (req, res, next) => { 98 | if (req.user && req.user.id) { 99 | req.body.user_id = req.user.id; 100 | } 101 | next(); 102 | }; 103 | 104 | // Error handling middleware for authentication errors 105 | const handleAuthError = (error, req, res, next) => { 106 | if (error.name === 'JsonWebTokenError') { 107 | return res.status(401).json({ 108 | error: 'Invalid token format.' 109 | }); 110 | } 111 | 112 | if (error.name === 'TokenExpiredError') { 113 | return res.status(401).json({ 114 | error: 'Token has expired.' 115 | }); 116 | } 117 | 118 | if (error.message.includes('authorization')) { 119 | return res.status(401).json({ 120 | error: error.message 121 | }); 122 | } 123 | 124 | next(error); 125 | }; 126 | 127 | module.exports = { 128 | authenticateToken, 129 | optionalAuth, 130 | validateResourceOwnership, 131 | addUserToBody, 132 | handleAuthError, 133 | isAdmin, 134 | requireAuth: authenticateToken // Alias for more readable routes 135 | }; 136 | -------------------------------------------------------------------------------- /server/src/services/emailService.js: -------------------------------------------------------------------------------- 1 | const nodemailer = require('nodemailer'); 2 | const settingsService = require('./settingsService'); 3 | 4 | class EmailService { 5 | constructor() { 6 | this.transporter = null; 7 | this.initialized = false; 8 | } 9 | 10 | async initialize() { 11 | try { 12 | // Get email settings from settings service 13 | const smtpHost = await settingsService.getSettingValue('smtp_host') || ''; 14 | const smtpPort = await settingsService.getSettingValue('smtp_port') || '587'; 15 | const smtpUser = await settingsService.getSettingValue('smtp_user') || ''; 16 | const smtpPass = await settingsService.getSettingValue('smtp_password') || ''; 17 | const emailFrom = await settingsService.getSettingValue('email_from') || 'noreply@webssh.example.com'; 18 | 19 | console.log(`Initializing EmailService with SMTP host: ${smtpHost}, port: ${smtpPort}`); 20 | 21 | // Create transporter 22 | this.transporter = nodemailer.createTransport({ 23 | host: smtpHost, 24 | port: parseInt(smtpPort, 10), 25 | secure: parseInt(smtpPort, 10) === 465, // true for 465, false for other ports 26 | auth: { 27 | user: smtpUser, 28 | pass: smtpPass, 29 | }, 30 | }); 31 | 32 | this.emailFrom = emailFrom; 33 | this.initialized = true; 34 | 35 | console.log('Email service initialized'); 36 | return true; 37 | } catch (error) { 38 | console.error('Failed to initialize email service:', error); 39 | return false; 40 | } 41 | } 42 | 43 | async sendPasswordResetEmail(to, resetLink, username) { 44 | if (!this.initialized) { 45 | await this.initialize(); 46 | } 47 | 48 | if (!this.transporter) { 49 | throw new Error('Email service not initialized'); 50 | } 51 | 52 | const siteName = await settingsService.getSettingValue('site_name') || 'IntelliSSH'; 53 | 54 | const mailOptions = { 55 | from: this.emailFrom, 56 | to, 57 | subject: `Password Reset Request - ${siteName}`, 58 | text: ` 59 | Hello ${username}, 60 | 61 | You recently requested to reset your password for your ${siteName} account. Click the link below to reset it: 62 | 63 | ${resetLink} 64 | 65 | This link will expire in 1 hour. 66 | 67 | If you did not request a password reset, please ignore this email or contact an administrator if you have concerns. 68 | 69 | Thanks, 70 | The ${siteName} Team 71 | `, 72 | html: ` 73 |
74 |

Password Reset Request

75 |

Hello ${username},

76 |

You recently requested to reset your password for your ${siteName} account. Click the button below to reset it:

77 |

78 | Reset Your Password 79 |

80 |

This link will expire in 1 hour.

81 |

If you did not request a password reset, please ignore this email or contact an administrator if you have concerns.

82 |

83 | Thanks,
84 | The ${siteName} Team 85 |

86 |

If the button above doesn't work, copy and paste this link into your browser: ${resetLink}

87 |
88 | ` 89 | }; 90 | 91 | try { 92 | const info = await this.transporter.sendMail(mailOptions); 93 | console.log('Password reset email sent:', info.messageId); 94 | return { success: true, messageId: info.messageId }; 95 | } catch (error) { 96 | console.error('Failed to send password reset email:', error); 97 | throw error; 98 | } 99 | } 100 | 101 | // Test email configuration 102 | async testEmailConfig() { 103 | if (!this.initialized) { 104 | await this.initialize(); 105 | } 106 | 107 | if (!this.transporter) { 108 | throw new Error('Email service not initialized'); 109 | } 110 | 111 | try { 112 | const result = await this.transporter.verify(); 113 | return { success: true, message: 'Email configuration verified successfully' }; 114 | } catch (error) { 115 | console.error('Email configuration verification failed:', error); 116 | return { success: false, error: error.message }; 117 | } 118 | } 119 | } 120 | 121 | module.exports = new EmailService(); 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📡 IntelliSSH 2 | 3 | A secure and user-friendly web app for managing Linux servers with Artifical Intelligence via SSH—right from your browser + SFTP Browser in Terminal. 4 | 5 | ![Version](https://img.shields.io/badge/version-1.2.0-blue) 6 | ![License](https://img.shields.io/badge/license-MIT-blue) 7 | 8 | Support this project:
9 | [![Patreon](https://img.shields.io/badge/Patreon-F96854?style=for-the-badge&logo=patreon&logoColor=white)](https://www.patreon.com/c/clusterzx) 10 | [![PayPal](https://img.shields.io/badge/PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](https://www.paypal.com/paypalme/bech0r) 11 | [![BuyMeACoffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/clusterzx) 12 | [![Ko-Fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/clusterzx) 13 | 14 | ![preview](https://github.com/clusterzx/intellissh/blob/master/preview.gif) 15 | 16 | ## IMPORTANT INFORMATION REGARDING CREDENTIALS: 17 | The default Admin credentials are shown on first startup in the Docker Logs! 18 | Sample: 19 | ``` 20 | ======================================================== 21 | INITIAL ADMIN ACCOUNT CREATED 22 | Username: admin 23 | Password: b99c192f24ba9e4f 24 | Please log in and change this password immediately! 25 | ======================================================== 26 | ``` 27 | 28 | ## 🚀 Overview 29 | 30 | IntelliSSH helps system administrators and developers access and control remote Linux servers with: 31 | 32 | - Browser-based SSH access (via xterm.js) 33 | - Full SFTP Client in Terminal (Download, Upload (Files/Folder), Create Folder, Delete) 34 | - Centralized and secure session management 35 | - Support for password and key-based auth 36 | - Real-time terminal via WebSocket 37 | - AI-powered assistance (OpenAI or Ollama) 38 | - Responsive UI with dark mode 39 | 40 | ## 🔐 Key Features 41 | 42 | - **Authentication**: Secure login with JWT and bcrypt 43 | - **Credential Management**: Securely manage credentials for SSH session connections. 44 | - **Two-Factor Authentication (TOTP)**: Enhance security with Time-based One-Time Password verification. 45 | - **SSH Sessions**: Create, edit, test, and connect 46 | - **Terminal**: Full emulation, copy/paste, resize 47 | - **AI Assistant**: Context-aware help and suggestions 48 | - **Security**: Encrypted credential storage, rate limiting 49 | - **Deployment**: Local or Docker-based deployment 50 | 51 | ## 🧱 Architecture 52 | 53 | ``` 54 | Frontend (Vue) <--> Backend (Express) 55 | ↕ ↕ 56 | WebSocket SSH2, LLM, DB 57 | ``` 58 | 59 | ## ⚡ Quick Start 60 | 61 | ### 🧪 Development 62 | 63 | ```bash 64 | git clone https://github.com/clusterzx/intellissh 65 | cd intellissh 66 | 67 | # Backend 68 | cd server && npm install && cp .env.example .env && npm run dev 69 | 70 | # Frontend (new terminal) 71 | cd client && npm install && npm run dev 72 | ``` 73 | 74 | - **Web**: [http://localhost:8080](http://localhost:8080) 75 | - **API**: [http://localhost:3000](http://localhost:3000) 76 | 77 | --- 78 | 79 | ### 🚀 Production (Docker) 80 | 81 | #### Run with port mapping (adjust ports as needed) 82 | ```bash 83 | docker run -d -p 8080:3000 --name intellissh clusterzx/intellissh:latest 84 | ``` 85 | 86 | #### Run with volume mounts for persistence 87 | ```bash 88 | docker run -d \ 89 | -p 8080:3000 \ 90 | -v $(pwd)/data:/app/server/data \ 91 | --name intellissh \ 92 | clusterzx/intellissh:latest 93 | ``` 94 | 95 | #### Docker Compose 96 | 97 | ```yaml 98 | services: 99 | intellissh: 100 | image: clusterzx/intellissh:latest 101 | container_name: intellissh 102 | ports: 103 | - 8080:3000 104 | volumes: 105 | # Mount for persistent backend data (SQLite DB, session info, etc.) 106 | - ./data:/app/server/data 107 | restart: unless-stopped 108 | ``` 109 | --- 110 | 111 | ## 📚 Documentation 112 | 113 | - **API**: REST endpoints for auth, sessions, and settings 114 | - **WebSocket**: Real-time terminal and LLM communication 115 | - **Usage**: Add SSH sessions, connect, manage profile, enable AI assistant 116 | 117 | ## 🛠 Tech Stack 118 | 119 | - Vue 3 + TailwindCSS 120 | - Express.js + SQLite 121 | - SSH2, Socket.IO, xterm.js 122 | - OpenAI / Ollama for AI 123 | - Docker for deployment 124 | 125 | ## 📌 Roadmap Highlights 126 | 127 | - SFTP file browser ✅ 128 | - Activity logging ⏳ 129 | - Multi-user session sharing ⏳ 130 | - Bulk operations & SSH key manager ⏳ 131 | - i18n and mobile apps ⏳ 132 | 133 | ## 🤝 Contributing 134 | 135 | We welcome contributions! Please fork the repo, create a branch, and submit a PR. 136 | 137 | ## 🛡️ License 138 | 139 | MIT License — see [LICENSE](./LICENSE) for details. 140 | 141 | --- 142 | 143 | > **Note**: IntelliSSH handles SSH credentials—secure your deployment appropriately. 144 | -------------------------------------------------------------------------------- /client/src/components/CredentialCard.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 120 | 121 | 134 | -------------------------------------------------------------------------------- /client/src/views/ForgotPasswordView.vue: -------------------------------------------------------------------------------- 1 | 90 | 91 | 131 | -------------------------------------------------------------------------------- /server/src/services/credentialService.js: -------------------------------------------------------------------------------- 1 | const db = require('../db/database'); 2 | const encryptionService = require('./encryptionService'); 3 | 4 | const CREDENTIAL_TYPES = { 5 | PASSWORD: 'password', 6 | PRIVATE_KEY: 'private_key', 7 | }; 8 | 9 | async function createCredential(userId, name, type, username, password, privateKey, passphrase) { 10 | // Encrypt sensitive data 11 | let encryptedPassword = null; 12 | let encryptedPrivateKey = null; 13 | let encryptedPassphrase = null; 14 | let iv = null; 15 | 16 | if (type === CREDENTIAL_TYPES.PASSWORD && password) { 17 | const encrypted = encryptionService.encrypt(password); 18 | encryptedPassword = encrypted.encryptedData; 19 | iv = encrypted.iv; 20 | } else if (type === CREDENTIAL_TYPES.PRIVATE_KEY && privateKey) { 21 | const encrypted = encryptionService.encrypt(privateKey); 22 | encryptedPrivateKey = encrypted.encryptedData; 23 | iv = encrypted.iv; 24 | if (passphrase) { 25 | const encryptedPassphraseData = encryptionService.encrypt(passphrase, iv); 26 | encryptedPassphrase = encryptedPassphraseData.encryptedData; 27 | } 28 | } 29 | 30 | const sql = ` 31 | INSERT INTO credentials (user_id, name, type, username, password, private_key, passphrase, iv) 32 | VALUES (?, ?, ?, ?, ?, ?, ?, ?) 33 | `; 34 | const params = [ 35 | userId, name, type, username, 36 | encryptedPassword, encryptedPrivateKey, encryptedPassphrase, iv 37 | ]; 38 | 39 | try { 40 | const result = await db.run(sql, params); 41 | return { id: result.id, name, type, username }; // Return basic info, not sensitive data 42 | } catch (error) { 43 | if (error.message.includes('UNIQUE constraint failed: credentials.user_id, credentials.name')) { 44 | throw new Error('A credential with this name already exists for this user.'); 45 | } 46 | throw new Error(`Failed to create credential: ${error.message}`); 47 | } 48 | } 49 | 50 | async function getCredentialsByUserId(userId) { 51 | const sql = 'SELECT id, name, type, username, created_at, updated_at FROM credentials WHERE user_id = ?'; 52 | try { 53 | const credentials = await db.all(sql, [userId]); 54 | return credentials; 55 | } catch (error) { 56 | throw new Error(`Failed to retrieve credentials: ${error.message}`); 57 | } 58 | } 59 | 60 | async function getCredentialById(credentialId, userId) { 61 | const sql = 'SELECT * FROM credentials WHERE id = ? AND user_id = ?'; 62 | try { 63 | const credential = await db.get(sql, [credentialId, userId]); 64 | if (!credential) { 65 | return null; 66 | } 67 | 68 | // Decrypt sensitive data before returning 69 | if (credential.type === CREDENTIAL_TYPES.PASSWORD && credential.password) { 70 | credential.password = encryptionService.decrypt(credential.password, credential.iv); 71 | } else if (credential.type === CREDENTIAL_TYPES.PRIVATE_KEY && credential.private_key) { 72 | credential.private_key = encryptionService.decrypt(credential.private_key, credential.iv); 73 | if (credential.passphrase) { 74 | credential.passphrase = encryptionService.decrypt(credential.passphrase, credential.iv); 75 | } 76 | } 77 | return credential; 78 | } catch (error) { 79 | throw new Error(`Failed to retrieve credential: ${error.message}`); 80 | } 81 | } 82 | 83 | async function updateCredential(credentialId, userId, name, type, username, password, privateKey, passphrase) { 84 | // Fetch existing credential to get current IV if not provided for update 85 | const existingCredential = await db.get('SELECT iv FROM credentials WHERE id = ? AND user_id = ?', [credentialId, userId]); 86 | if (!existingCredential) { 87 | throw new Error('Credential not found or unauthorized.'); 88 | } 89 | 90 | let encryptedPassword = null; 91 | let encryptedPrivateKey = null; 92 | let encryptedPassphrase = null; 93 | let iv = existingCredential.iv; // Use existing IV by default 94 | 95 | // Re-encrypt if sensitive data is provided for update 96 | if (type === CREDENTIAL_TYPES.PASSWORD && password !== undefined) { 97 | const encrypted = encryptionService.encrypt(password); 98 | encryptedPassword = encrypted.encryptedData; 99 | iv = encrypted.iv; 100 | } else if (type === CREDENTIAL_TYPES.PRIVATE_KEY && privateKey !== undefined) { 101 | const encrypted = encryptionService.encrypt(privateKey); 102 | encryptedPrivateKey = encrypted.encryptedData; 103 | iv = encrypted.iv; 104 | if (passphrase !== undefined) { 105 | const encryptedPassphraseData = encryptionService.encrypt(passphrase, iv); 106 | encryptedPassphrase = encryptedPassphraseData.encryptedData; 107 | } 108 | } 109 | 110 | const sql = ` 111 | UPDATE credentials 112 | SET name = ?, 113 | type = ?, 114 | username = ?, 115 | password = ?, 116 | private_key = ?, 117 | passphrase = ?, 118 | iv = ?, 119 | updated_at = CURRENT_TIMESTAMP 120 | WHERE id = ? AND user_id = ? 121 | `; 122 | const params = [ 123 | name, type, username, 124 | encryptedPassword, encryptedPrivateKey, encryptedPassphrase, iv, 125 | credentialId, userId 126 | ]; 127 | 128 | try { 129 | const result = await db.run(sql, params); 130 | if (result.changes === 0) { 131 | throw new Error('Credential not found or no changes made.'); 132 | } 133 | return { id: credentialId, name, type, username }; 134 | } catch (error) { 135 | if (error.message.includes('UNIQUE constraint failed: credentials.user_id, credentials.name')) { 136 | throw new Error('A credential with this name already exists for this user.'); 137 | } 138 | throw new Error(`Failed to update credential: ${error.message}`); 139 | } 140 | } 141 | 142 | async function deleteCredential(credentialId, userId) { 143 | const sql = 'DELETE FROM credentials WHERE id = ? AND user_id = ?'; 144 | try { 145 | const result = await db.run(sql, [credentialId, userId]); 146 | if (result.changes === 0) { 147 | throw new Error('Credential not found or unauthorized.'); 148 | } 149 | return { message: 'Credential deleted successfully.' }; 150 | } catch (error) { 151 | throw new Error(`Failed to delete credential: ${error.message}`); 152 | } 153 | } 154 | 155 | module.exports = { 156 | CREDENTIAL_TYPES, 157 | createCredential, 158 | getCredentialsByUserId, 159 | getCredentialById, 160 | updateCredential, 161 | deleteCredential, 162 | }; 163 | -------------------------------------------------------------------------------- /client/src/stores/llmHelperStore.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { ref, computed } from 'vue' 3 | import { useTerminalStore } from './terminalStore' 4 | 5 | export const useLLMHelperStore = defineStore('llmHelper', () => { 6 | // State 7 | const enabled = ref(false) 8 | const isProcessing = ref(false) 9 | const settings = ref({ 10 | provider: 'openai', 11 | model: 'gpt-3.5-turbo', 12 | availableProviders: [], 13 | availableModels: {} 14 | }) 15 | const lastResponse = ref(null) 16 | const lastCommand = ref(null) 17 | const error = ref(null) 18 | const history = ref([]) 19 | const pendingCommand = ref(null) // To store command waiting for approval 20 | 21 | // Getters 22 | const isEnabled = computed(() => enabled.value) 23 | const isReady = computed(() => { 24 | const terminalStore = useTerminalStore() 25 | return terminalStore.hasActiveSession 26 | }) 27 | 28 | // Actions 29 | const toggleHelper = async (value) => { 30 | const terminalStore = useTerminalStore() 31 | if (!terminalStore.socket?.connected || !terminalStore.hasActiveSession) { 32 | error.value = 'Terminal not connected' 33 | return false 34 | } 35 | 36 | enabled.value = value 37 | terminalStore.socket.emit('toggle-llm-helper', { enabled: value }) 38 | return true 39 | } 40 | 41 | const sendManualPrompt = async (prompt) => { 42 | const terminalStore = useTerminalStore() 43 | if (!terminalStore.socket?.connected || !terminalStore.hasActiveSession) { 44 | error.value = 'Terminal not connected' 45 | return false 46 | } 47 | 48 | if (!enabled.value) { 49 | error.value = 'LLM Helper is disabled' 50 | return false 51 | } 52 | 53 | isProcessing.value = true 54 | try { 55 | terminalStore.socket.emit('llm-manual-prompt', { prompt }) 56 | // Response will be handled by socket events 57 | return true 58 | } catch (err) { 59 | error.value = err.message 60 | return false 61 | } finally { 62 | isProcessing.value = false 63 | } 64 | } 65 | 66 | const fetchSettings = async () => { 67 | const terminalStore = useTerminalStore() 68 | if (!terminalStore.socket?.connected) { 69 | error.value = 'Terminal not connected' 70 | return false 71 | } 72 | 73 | terminalStore.socket.emit('get-llm-settings') 74 | return true 75 | } 76 | 77 | const setupSocketListeners = () => { 78 | const terminalStore = useTerminalStore() 79 | if (!terminalStore.socket) return 80 | 81 | terminalStore.socket.on('llm-helper-status', (data) => { 82 | enabled.value = data.enabled 83 | }) 84 | 85 | // Listen for LLM processing status updates 86 | terminalStore.socket.on('llm-processing-start', () => { 87 | console.log('LLM processing started') 88 | isProcessing.value = true 89 | }) 90 | 91 | terminalStore.socket.on('llm-processing-end', () => { 92 | console.log('LLM processing ended') 93 | // Don't set isProcessing to false here, as we'll do that when we receive the response 94 | }) 95 | 96 | terminalStore.socket.on('llm-response', (data) => { 97 | lastResponse.value = data 98 | history.value.push({ 99 | type: 'response', 100 | content: data, 101 | timestamp: new Date().toISOString() 102 | }) 103 | isProcessing.value = false 104 | }) 105 | 106 | terminalStore.socket.on('llm-executed-command', (data) => { 107 | lastCommand.value = data 108 | history.value.push({ 109 | type: 'command', 110 | content: data, 111 | timestamp: new Date().toISOString() 112 | }) 113 | }) 114 | 115 | terminalStore.socket.on('llm-error', (data) => { 116 | error.value = data.message 117 | isProcessing.value = false 118 | }) 119 | 120 | terminalStore.socket.on('llm-settings', (data) => { 121 | settings.value = data 122 | }) 123 | 124 | // Listen for command suggestions that require approval 125 | terminalStore.socket.on('llm-command-suggestion', (data) => { 126 | pendingCommand.value = data 127 | history.value.push({ 128 | type: 'suggestion', 129 | content: data, 130 | timestamp: new Date().toISOString() 131 | }) 132 | }) 133 | 134 | // Listen for executed command confirmations 135 | terminalStore.socket.on('llm-command-executed', (data) => { 136 | pendingCommand.value = null 137 | lastCommand.value = data 138 | history.value.push({ 139 | type: 'executed', 140 | content: data, 141 | timestamp: new Date().toISOString() 142 | }) 143 | }) 144 | } 145 | 146 | const clearHistory = () => { 147 | history.value = [] 148 | lastResponse.value = null 149 | lastCommand.value = null 150 | } 151 | 152 | const clearError = () => { 153 | error.value = null 154 | } 155 | 156 | // Execute a command that was suggested by the LLM 157 | const approveCommand = async () => { 158 | const terminalStore = useTerminalStore() 159 | if (!terminalStore.socket?.connected || !terminalStore.hasActiveSession) { 160 | error.value = 'Terminal not connected' 161 | return false 162 | } 163 | 164 | if (!pendingCommand.value) { 165 | error.value = 'No command pending approval' 166 | return false 167 | } 168 | 169 | try { 170 | terminalStore.socket.emit('execute-approved-command', { 171 | command: pendingCommand.value.command 172 | }) 173 | return true 174 | } catch (err) { 175 | error.value = err.message 176 | return false 177 | } 178 | } 179 | 180 | // Reject a command that was suggested by the LLM 181 | const rejectCommand = () => { 182 | // Just clear the pending command without executing it 183 | pendingCommand.value = null; 184 | 185 | history.value.push({ 186 | type: 'rejected', 187 | content: { message: 'Command rejected by user' }, 188 | timestamp: new Date().toISOString() 189 | }); 190 | 191 | return true; 192 | } 193 | 194 | // Manually trigger analysis of the current terminal output 195 | const analyzeLastTerminalOutput = async () => { 196 | const terminalStore = useTerminalStore(); 197 | if (!terminalStore.socket?.connected || !terminalStore.hasActiveSession) { 198 | error.value = 'Terminal not connected'; 199 | return false; 200 | } 201 | 202 | if (!enabled.value) { 203 | error.value = 'LLM Helper is not enabled'; 204 | return false; 205 | } 206 | 207 | isProcessing.value = true; 208 | try { 209 | terminalStore.socket.emit('llm-analyze-terminal'); 210 | // Response will be handled by socket events 211 | return true; 212 | } catch (err) { 213 | error.value = err.message; 214 | return false; 215 | } finally { 216 | // isProcessing.value will be set to false when we receive the response 217 | } 218 | } 219 | 220 | // Methods for handling history items 221 | const setPendingCommandFromHistory = (commandData) => { 222 | pendingCommand.value = commandData; 223 | } 224 | 225 | const removeHistoryItem = (item) => { 226 | const index = history.value.findIndex(i => i === item); 227 | if (index !== -1) { 228 | history.value.splice(index, 1); 229 | } 230 | } 231 | 232 | return { 233 | // State 234 | enabled, 235 | isProcessing, 236 | settings, 237 | lastResponse, 238 | lastCommand, 239 | error, 240 | history, 241 | pendingCommand, 242 | 243 | // Getters 244 | isEnabled, 245 | isReady, 246 | 247 | // Actions 248 | toggleHelper, 249 | sendManualPrompt, 250 | fetchSettings, 251 | setupSocketListeners, 252 | clearHistory, 253 | clearError, 254 | approveCommand, 255 | rejectCommand, 256 | analyzeLastTerminalOutput, 257 | setPendingCommandFromHistory, 258 | removeHistoryItem 259 | } 260 | }) 261 | -------------------------------------------------------------------------------- /client/src/stores/settingsStore.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia'; 2 | import axios from 'axios'; 3 | import { useAuthStore } from './authStore'; 4 | 5 | export const useSettingsStore = defineStore('settings', { 6 | state: () => ({ 7 | settings: [], 8 | categories: [], 9 | loading: false, 10 | error: null, 11 | initialized: false 12 | }), 13 | 14 | getters: { 15 | // Get settings by category 16 | getSettingsByCategory: (state) => (category) => { 17 | return state.settings.filter(setting => setting.category === category); 18 | }, 19 | 20 | // Get a single setting by id 21 | getSettingById: (state) => (id) => { 22 | return state.settings.find(setting => setting.id === id); 23 | }, 24 | 25 | // Get value of a setting by id 26 | getSettingValue: (state) => (id) => { 27 | const setting = state.settings.find(setting => setting.id === id); 28 | return setting ? setting.value : null; 29 | }, 30 | 31 | // Check if settings are loaded 32 | isLoaded: (state) => state.initialized && !state.loading, 33 | 34 | // Get unique categories 35 | uniqueCategories: (state) => { 36 | return [...new Set(state.settings.map(setting => setting.category))]; 37 | } 38 | }, 39 | 40 | actions: { 41 | // Fetch all settings 42 | async fetchSettings(forUser = false) { 43 | this.loading = true; 44 | this.error = null; 45 | 46 | try { 47 | const url = forUser ? '/api/settings?user=me' : '/api/settings'; 48 | const response = await axios.get(url); 49 | this.settings = response.data; 50 | this.initialized = true; 51 | 52 | // Extract unique categories 53 | this.categories = [...new Set(this.settings.map(setting => setting.category))]; 54 | 55 | return this.settings; 56 | } catch (error) { 57 | console.error('Error fetching settings:', error); 58 | 59 | // If access denied trying to fetch global settings, try user settings 60 | if (!forUser && error.response?.status === 403) { 61 | return this.fetchSettings(true); 62 | } 63 | 64 | this.error = error.response?.data?.error || 'Failed to load settings'; 65 | throw error; 66 | } finally { 67 | this.loading = false; 68 | } 69 | }, 70 | 71 | // Fetch settings by category 72 | async fetchSettingsByCategory(category, forUser = false) { 73 | this.loading = true; 74 | this.error = null; 75 | 76 | try { 77 | const url = forUser ? 78 | `/api/settings/${category}?user=me` : 79 | `/api/settings/${category}`; 80 | 81 | const response = await axios.get(url); 82 | 83 | // Update only the settings for this category 84 | const categorySettings = response.data; 85 | 86 | // Remove existing settings for this category 87 | this.settings = this.settings.filter(setting => setting.category !== category); 88 | 89 | // Add the new settings for this category 90 | this.settings.push(...categorySettings); 91 | 92 | return categorySettings; 93 | } catch (error) { 94 | console.error(`Error fetching ${category} settings:`, error); 95 | 96 | // If access denied trying to fetch global settings, try user settings 97 | if (!forUser && error.response?.status === 403) { 98 | return this.fetchSettingsByCategory(category, true); 99 | } 100 | 101 | this.error = error.response?.data?.error || `Failed to load ${category} settings`; 102 | throw error; 103 | } finally { 104 | this.loading = false; 105 | } 106 | }, 107 | 108 | // Update multiple settings 109 | async updateSettings(settingsToUpdate, forUser = false) { 110 | this.loading = true; 111 | this.error = null; 112 | 113 | try { 114 | const payload = forUser ? 115 | { settings: settingsToUpdate, user: 'me' } : 116 | { settings: settingsToUpdate }; 117 | 118 | const response = await axios.put('/api/settings', payload); 119 | 120 | // Update local settings with new values 121 | settingsToUpdate.forEach(updatedSetting => { 122 | const index = this.settings.findIndex(s => s.id === updatedSetting.id); 123 | if (index !== -1) { 124 | this.settings[index].value = updatedSetting.value; 125 | this.settings[index].updated_at = new Date().toISOString(); 126 | } 127 | }); 128 | 129 | return response.data; 130 | } catch (error) { 131 | console.error('Error updating settings:', error); 132 | 133 | // If access denied trying to update global settings, try user settings 134 | if (!forUser && error.response?.status === 403) { 135 | return this.updateSettings(settingsToUpdate, true); 136 | } 137 | 138 | this.error = error.response?.data?.error || 'Failed to update settings'; 139 | throw error; 140 | } finally { 141 | this.loading = false; 142 | } 143 | }, 144 | 145 | // Update a single setting 146 | async updateSetting(id, value, forUser = false) { 147 | this.loading = true; 148 | this.error = null; 149 | 150 | try { 151 | const payload = forUser ? 152 | { value, user: 'me' } : 153 | { value }; 154 | 155 | const response = await axios.put(`/api/settings/${id}`, payload); 156 | 157 | // Update local setting with new value 158 | const index = this.settings.findIndex(s => s.id === id); 159 | if (index !== -1) { 160 | this.settings[index].value = value; 161 | this.settings[index].updated_at = new Date().toISOString(); 162 | } 163 | 164 | return response.data; 165 | } catch (error) { 166 | console.error(`Error updating setting ${id}:`, error); 167 | 168 | // If access denied trying to update global setting, try user setting 169 | if (!forUser && error.response?.status === 403) { 170 | return this.updateSetting(id, value, true); 171 | } 172 | 173 | this.error = error.response?.data?.error || 'Failed to update setting'; 174 | throw error; 175 | } finally { 176 | this.loading = false; 177 | } 178 | }, 179 | 180 | // Reset settings to defaults 181 | async resetSettings(forUser = false) { 182 | this.loading = true; 183 | this.error = null; 184 | 185 | try { 186 | console.log('Sending reset request to API'); 187 | 188 | const payload = forUser ? { user: 'me' } : {}; 189 | const response = await axios.post('/api/settings/reset', payload); 190 | 191 | console.log('Reset response:', response.data); 192 | 193 | // Reload settings after reset 194 | console.log('Reloading settings after reset'); 195 | await this.fetchSettings(forUser); 196 | console.log('Settings reloaded successfully'); 197 | 198 | return response.data; 199 | } catch (error) { 200 | console.error('Error resetting settings:', error); 201 | 202 | // If access denied trying to reset global settings, try user settings 203 | if (!forUser && error.response?.status === 403) { 204 | return this.resetSettings(true); 205 | } 206 | 207 | this.error = error.response?.data?.error || 'Failed to reset settings'; 208 | 209 | // Log detailed error information 210 | if (error.response) { 211 | console.error('Response data:', error.response.data); 212 | console.error('Response status:', error.response.status); 213 | console.error('Response headers:', error.response.headers); 214 | } else if (error.request) { 215 | console.error('No response received:', error.request); 216 | } else { 217 | console.error('Error setting up request:', error.message); 218 | } 219 | 220 | throw error; 221 | } finally { 222 | this.loading = false; 223 | } 224 | }, 225 | 226 | // Check if user is admin 227 | isAdmin() { 228 | const authStore = useAuthStore(); 229 | return authStore.user?.role === 'admin'; 230 | } 231 | } 232 | }); 233 | -------------------------------------------------------------------------------- /server/src/api/files.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const multer = require('multer'); 6 | const { v4: uuidv4 } = require('uuid'); 7 | const sftpService = require('../services/sftpService'); 8 | const { authenticateToken } = require('../middleware/authMiddleware'); 9 | 10 | // Set up temp directory for file uploads/downloads 11 | const tempDir = path.join(__dirname, '../../temp'); 12 | if (!fs.existsSync(tempDir)) { 13 | fs.mkdirSync(tempDir, { recursive: true }); 14 | } 15 | 16 | // Set up multer for handling file uploads 17 | const storage = multer.diskStorage({ 18 | destination: function(req, file, cb) { 19 | // Create a user-specific directory 20 | const userDir = path.join(tempDir, req.user.id.toString()); 21 | if (!fs.existsSync(userDir)) { 22 | fs.mkdirSync(userDir, { recursive: true }); 23 | } 24 | cb(null, userDir); 25 | }, 26 | filename: function(req, file, cb) { 27 | // Use a UUID to prevent filename collisions, but keep the original extension 28 | const extension = path.extname(file.originalname); 29 | const uniqueFilename = `${uuidv4()}${extension}`; 30 | cb(null, uniqueFilename); 31 | } 32 | }); 33 | 34 | const upload = multer({ storage: storage }); 35 | 36 | // Clean up temp files older than 1 hour 37 | const cleanupTempFiles = () => { 38 | try { 39 | const now = Date.now(); 40 | const oneHour = 60 * 60 * 1000; 41 | 42 | // Read all user directories 43 | if (fs.existsSync(tempDir)) { 44 | const userDirs = fs.readdirSync(tempDir); 45 | userDirs.forEach(userId => { 46 | const userDir = path.join(tempDir, userId); 47 | 48 | // Skip if not a directory 49 | if (!fs.existsSync(userDir) || !fs.statSync(userDir).isDirectory()) return; 50 | 51 | const files = fs.readdirSync(userDir); 52 | files.forEach(file => { 53 | const filePath = path.join(userDir, file); 54 | const stats = fs.statSync(filePath); 55 | 56 | // Delete files older than one hour 57 | if (now - stats.mtime.getTime() > oneHour) { 58 | fs.unlinkSync(filePath); 59 | console.log(`Deleted old temp file: ${filePath}`); 60 | } 61 | }); 62 | 63 | // Remove directory if empty 64 | if (fs.readdirSync(userDir).length === 0) { 65 | fs.rmdirSync(userDir); 66 | console.log(`Removed empty user directory: ${userDir}`); 67 | } 68 | }); 69 | } 70 | } catch (error) { 71 | console.error('Error during temp file cleanup:', error); 72 | } 73 | }; 74 | 75 | // Run cleanup every hour 76 | setInterval(cleanupTempFiles, 60 * 60 * 1000); 77 | 78 | // Initialize directories 79 | try { 80 | if (!fs.existsSync(tempDir)) { 81 | fs.mkdirSync(tempDir, { recursive: true }); 82 | console.log('Created temp directory at:', tempDir); 83 | } 84 | } catch (error) { 85 | console.error('Failed to create temp directory:', error); 86 | } 87 | 88 | // File upload handler 89 | const handleFileUpload = async (req, res) => { 90 | try { 91 | if (!req.file) { 92 | return res.status(400).json({ error: 'No file provided' }); 93 | } 94 | 95 | // Return the local server path and file info 96 | res.json({ 97 | success: true, 98 | localPath: req.file.path, 99 | filename: req.file.originalname, 100 | size: req.file.size, 101 | mimetype: req.file.mimetype 102 | }); 103 | } catch (error) { 104 | console.error('File upload error:', error); 105 | res.status(500).json({ error: error.message }); 106 | } 107 | }; 108 | 109 | // SFTP upload handler 110 | const handleSftpUpload = async (req, res) => { 111 | try { 112 | const { localPath, remotePath, connectionId } = req.body; 113 | 114 | if (!localPath || !remotePath || !connectionId) { 115 | return res.status(400).json({ error: 'Missing required parameters' }); 116 | } 117 | 118 | // Verify file exists 119 | if (!fs.existsSync(localPath)) { 120 | return res.status(404).json({ error: 'File not found on server' }); 121 | } 122 | 123 | // Upload to SFTP 124 | const result = await sftpService.uploadFile(connectionId, localPath, remotePath); 125 | 126 | // Return success 127 | res.json(result); 128 | 129 | // Schedule the temp file for deletion after a delay 130 | setTimeout(() => { 131 | try { 132 | if (fs.existsSync(localPath)) { 133 | fs.unlinkSync(localPath); 134 | console.log(`Deleted temp file after upload: ${localPath}`); 135 | } 136 | } catch (error) { 137 | console.error(`Failed to delete temp file: ${localPath}`, error); 138 | } 139 | }, 5 * 60 * 1000); // Delete after 5 minutes 140 | 141 | } catch (error) { 142 | console.error('SFTP upload error:', error); 143 | res.status(500).json({ error: error.message }); 144 | } 145 | }; 146 | 147 | // SFTP download handler 148 | const handleSftpDownload = async (req, res) => { 149 | try { 150 | const { remotePath, connectionId } = req.body; 151 | 152 | if (!remotePath || !connectionId) { 153 | return res.status(400).json({ error: 'Missing required parameters' }); 154 | } 155 | 156 | // Create a unique filename in the user's temp directory 157 | const userDir = path.join(tempDir, req.user.id.toString()); 158 | if (!fs.existsSync(userDir)) { 159 | fs.mkdirSync(userDir, { recursive: true }); 160 | } 161 | 162 | const filename = path.basename(remotePath); 163 | const extension = path.extname(filename); 164 | const uniqueFilename = `${uuidv4()}${extension}`; 165 | const localPath = path.join(userDir, uniqueFilename); 166 | 167 | // Download from SFTP 168 | const result = await sftpService.downloadFile(connectionId, remotePath, localPath); 169 | 170 | // Return success with the local path for browser download 171 | res.json({ 172 | ...result, 173 | downloadUrl: `/api/files/download/${req.user.id}/${uniqueFilename}`, 174 | originalFilename: filename 175 | }); 176 | 177 | } catch (error) { 178 | console.error('SFTP download error:', error); 179 | res.status(500).json({ error: error.message }); 180 | } 181 | }; 182 | 183 | // File download handler 184 | const handleFileDownload = async (req, res) => { 185 | try { 186 | const { userId, filename } = req.params; 187 | 188 | const filePath = path.join(tempDir, userId, filename); 189 | 190 | // Verify file exists 191 | if (!fs.existsSync(filePath)) { 192 | return res.status(404).send('File not found'); 193 | } 194 | 195 | // Get original filename from query or use the current filename 196 | const originalFilename = req.query.name || filename; 197 | 198 | // Set headers for file download 199 | res.setHeader('Content-Disposition', `attachment; filename="${originalFilename}"`); 200 | 201 | // Send the file 202 | res.sendFile(filePath, (err) => { 203 | if (err) { 204 | console.error('Error sending file:', err); 205 | } else { 206 | // Schedule the file for deletion after a delay 207 | setTimeout(() => { 208 | try { 209 | if (fs.existsSync(filePath)) { 210 | fs.unlinkSync(filePath); 211 | console.log(`Deleted temp file after download: ${filePath}`); 212 | } 213 | } catch (error) { 214 | console.error(`Failed to delete temp file: ${filePath}`, error); 215 | } 216 | }, 5 * 60 * 1000); // Delete after 5 minutes 217 | } 218 | }); 219 | 220 | } catch (error) { 221 | console.error('File download error:', error); 222 | res.status(500).send('Server error'); 223 | } 224 | }; 225 | 226 | // Register routes 227 | router.post('/upload', authenticateToken, upload.single('file'), handleFileUpload); 228 | router.post('/sftp-upload', authenticateToken, handleSftpUpload); 229 | router.post('/sftp-download', authenticateToken, handleSftpDownload); 230 | router.get('/download/:userId/:filename', handleFileDownload); 231 | 232 | module.exports = router; 233 | -------------------------------------------------------------------------------- /server/src/socket/terminal.js: -------------------------------------------------------------------------------- 1 | const sshService = require('../services/sshService'); 2 | const sftpService = require('../services/sftpService'); 3 | const sessionService = require('../services/sessionService'); 4 | const authService = require('../services/authService'); 5 | 6 | const handleSocketConnection = (io) => { 7 | io.on('connection', (socket) => { 8 | console.log(`Socket connected: ${socket.id}`); 9 | 10 | // Store user info on socket for authentication 11 | socket.authenticated = false; 12 | socket.userId = null; 13 | 14 | // Authentication middleware for socket 15 | socket.on('authenticate', async (data) => { 16 | try { 17 | const { token } = data; 18 | 19 | if (!token) { 20 | socket.emit('auth-error', { message: 'No token provided' }); 21 | return; 22 | } 23 | 24 | const decoded = authService.verifyToken(token); 25 | socket.authenticated = true; 26 | socket.userId = decoded.id; 27 | socket.username = decoded.username; 28 | 29 | socket.emit('authenticated', { 30 | success: true, 31 | user: { id: decoded.id, username: decoded.username } 32 | }); 33 | 34 | console.log(`Socket ${socket.id} authenticated as user ${decoded.username}`); 35 | } catch (error) { 36 | console.error('Socket authentication error:', error.message); 37 | socket.emit('auth-error', { message: 'Invalid token' }); 38 | } 39 | }); 40 | 41 | // Connect to SSH session 42 | socket.on('connect-session', async (data) => { 43 | try { 44 | if (!socket.authenticated) { 45 | socket.emit('connection-error', { message: 'Not authenticated' }); 46 | return; 47 | } 48 | 49 | const { sessionId } = data; 50 | 51 | if (!sessionId) { 52 | socket.emit('connection-error', { message: 'Session ID required' }); 53 | return; 54 | } 55 | 56 | console.log(`User ${socket.username} connecting to session ${sessionId}`); 57 | 58 | // Get session with credentials 59 | const sessionData = await sessionService.getSessionWithCredentials( 60 | parseInt(sessionId), 61 | socket.userId 62 | ); 63 | 64 | // Connect to SSH 65 | const connectionId = await sshService.connect(sessionData, socket); 66 | 67 | socket.connectionId = connectionId; 68 | socket.sessionId = sessionId; 69 | 70 | socket.emit('connection-established', { 71 | success: true, 72 | connectionId: connectionId, 73 | session: { 74 | id: sessionData.id, 75 | name: sessionData.name, 76 | hostname: sessionData.hostname, 77 | username: sessionData.username 78 | } 79 | }); 80 | 81 | console.log(`SSH connection established: ${connectionId}`); 82 | } catch (error) { 83 | console.error('SSH connection error:', error.message); 84 | socket.emit('connection-error', { 85 | message: error.message || 'Failed to connect to SSH session' 86 | }); 87 | } 88 | }); 89 | 90 | // Handle terminal input 91 | socket.on('terminal-input', (data) => { 92 | if (!socket.authenticated || !socket.connectionId) { 93 | return; 94 | } 95 | 96 | // The input handling is managed by the SSH service 97 | // This event is captured in the sshService.setupStreamHandlers method 98 | }); 99 | 100 | // Handle terminal resize 101 | socket.on('terminal-resize', (data) => { 102 | if (!socket.authenticated || !socket.connectionId) { 103 | return; 104 | } 105 | 106 | // The resize handling is managed by the SSH service 107 | // This event is captured in the sshService.setupStreamHandlers method 108 | }); 109 | 110 | // Connect to SFTP 111 | socket.on('connect-sftp', async (data) => { 112 | try { 113 | if (!socket.authenticated) { 114 | socket.emit('sftp-connection-error', { message: 'Not authenticated' }); 115 | return; 116 | } 117 | 118 | const { sessionId } = data; 119 | 120 | if (!sessionId) { 121 | socket.emit('sftp-connection-error', { message: 'Session ID required' }); 122 | return; 123 | } 124 | 125 | console.log(`User ${socket.username} connecting to SFTP session ${sessionId}`); 126 | 127 | // Get session with credentials 128 | const sessionData = await sessionService.getSessionWithCredentials( 129 | parseInt(sessionId), 130 | socket.userId 131 | ); 132 | 133 | // Connect to SFTP 134 | const result = await sftpService.connect(sessionData, socket); 135 | 136 | socket.sftpConnectionId = result.connectionId; 137 | 138 | socket.emit('sftp-connected', { 139 | success: true, 140 | connectionId: result.connectionId, 141 | session: { 142 | id: sessionData.id, 143 | name: sessionData.name, 144 | hostname: sessionData.hostname, 145 | username: sessionData.username 146 | } 147 | }); 148 | 149 | console.log(`SFTP connection established: ${result.connectionId}`); 150 | } catch (error) { 151 | console.error('SFTP connection error:', error.message); 152 | socket.emit('sftp-connection-error', { 153 | message: error.message || 'Failed to connect to SFTP session' 154 | }); 155 | } 156 | }); 157 | 158 | // Handle disconnect SSH session 159 | socket.on('disconnect-session', () => { 160 | if (socket.connectionId) { 161 | console.log(`Disconnecting SSH session: ${socket.connectionId}`); 162 | sshService.disconnect(socket.connectionId); 163 | socket.connectionId = null; 164 | socket.sessionId = null; 165 | } 166 | 167 | // Also disconnect SFTP if connected 168 | if (socket.sftpConnectionId) { 169 | console.log(`Disconnecting SFTP session: ${socket.sftpConnectionId}`); 170 | sftpService.disconnect(socket.sftpConnectionId); 171 | socket.sftpConnectionId = null; 172 | } 173 | }); 174 | 175 | // Handle disconnect SFTP session only 176 | socket.on('disconnect-sftp', () => { 177 | if (socket.sftpConnectionId) { 178 | console.log(`Disconnecting SFTP session: ${socket.sftpConnectionId}`); 179 | sftpService.disconnect(socket.sftpConnectionId); 180 | socket.sftpConnectionId = null; 181 | } 182 | }); 183 | 184 | // Handle socket disconnect 185 | socket.on('disconnect', (reason) => { 186 | console.log(`Socket ${socket.id} disconnected: ${reason}`); 187 | 188 | if (socket.connectionId) { 189 | console.log(`Cleaning up SSH connection: ${socket.connectionId}`); 190 | sshService.disconnect(socket.connectionId); 191 | } 192 | 193 | if (socket.sftpConnectionId) { 194 | console.log(`Cleaning up SFTP connection: ${socket.sftpConnectionId}`); 195 | sftpService.disconnect(socket.sftpConnectionId); 196 | } 197 | }); 198 | 199 | // Handle connection status request 200 | socket.on('get-connection-status', () => { 201 | if (!socket.authenticated) { 202 | socket.emit('connection-status', { connected: false, reason: 'Not authenticated' }); 203 | return; 204 | } 205 | 206 | const isConnected = socket.connectionId && sshService.getConnection(socket.connectionId); 207 | 208 | socket.emit('connection-status', { 209 | connected: !!isConnected, 210 | connectionId: socket.connectionId, 211 | sessionId: socket.sessionId 212 | }); 213 | }); 214 | 215 | // Handle ping/pong for keepalive 216 | socket.on('ping', () => { 217 | socket.emit('pong'); 218 | }); 219 | 220 | // Error handling 221 | socket.on('error', (error) => { 222 | console.error(`Socket ${socket.id} error:`, error); 223 | }); 224 | 225 | // Initial connection message 226 | socket.emit('connected', { 227 | message: 'Connected to IntelliSSH server', 228 | socketId: socket.id, 229 | timestamp: new Date().toISOString() 230 | }); 231 | }); 232 | 233 | // Periodic cleanup of stale connections 234 | setInterval(() => { 235 | const cleanedUpSSH = sshService.cleanupStaleConnections(); 236 | if (cleanedUpSSH > 0) { 237 | console.log(`Cleaned up ${cleanedUpSSH} stale SSH connections`); 238 | } 239 | 240 | const cleanedUpSFTP = sftpService.cleanupStaleConnections(); 241 | if (cleanedUpSFTP > 0) { 242 | console.log(`Cleaned up ${cleanedUpSFTP} stale SFTP connections`); 243 | } 244 | }, 5 * 60 * 1000); // Every 5 minutes 245 | }; 246 | 247 | module.exports = handleSocketConnection; 248 | -------------------------------------------------------------------------------- /client/src/stores/sessionStore.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import { ref, computed } from 'vue' 3 | import axios from 'axios' 4 | 5 | export const useSessionStore = defineStore('session', () => { 6 | // State 7 | const sessions = ref([]) 8 | const currentSession = ref(null) 9 | const loading = ref(false) 10 | const error = ref(null) 11 | 12 | // Getters 13 | const allSessions = computed(() => sessions.value) 14 | const isLoading = computed(() => loading.value) 15 | const sessionError = computed(() => error.value) 16 | const sessionCount = computed(() => sessions.value.length) 17 | 18 | // Actions 19 | const fetchSessions = async () => { 20 | // console.log('fetchSessions: Starting...') 21 | loading.value = true 22 | error.value = null 23 | 24 | try { 25 | const response = await axios.get('/api/sessions') 26 | sessions.value = response.data.sessions.map(session => ({ 27 | ...session, 28 | credentialId: session.credential_id // Map backend's credential_id to frontend's credentialId 29 | })) 30 | // console.log('fetchSessions: Successfully fetched sessions.') 31 | return { success: true } 32 | } catch (err) { 33 | // console.error('fetchSessions: Error fetching sessions:', err) 34 | error.value = err.response?.data?.error || 'Failed to fetch sessions' 35 | return { success: false, error: error.value } 36 | } finally { 37 | // console.log('fetchSessions: Finally block - setting loading to false.') 38 | loading.value = false 39 | } 40 | } 41 | 42 | const getSession = async (sessionId) => { 43 | loading.value = true 44 | error.value = null 45 | 46 | try { 47 | const response = await axios.get(`/api/sessions/${sessionId}`) 48 | currentSession.value = { 49 | ...response.data.session, 50 | credentialId: response.data.session.credential_id // Map backend's credential_id 51 | } 52 | return { success: true, session: currentSession.value } 53 | } catch (err) { 54 | error.value = err.response?.data?.error || 'Failed to fetch session' 55 | return { success: false, error: error.value } 56 | } finally { 57 | loading.value = false 58 | } 59 | } 60 | 61 | const createSession = async (sessionData) => { 62 | loading.value = true 63 | error.value = null 64 | 65 | try { 66 | const response = await axios.post('/api/sessions', sessionData) 67 | const newSession = response.data.session 68 | 69 | // Add to local sessions array 70 | sessions.value.unshift(newSession) 71 | 72 | return { success: true, session: newSession } 73 | } catch (err) { 74 | error.value = err.response?.data?.error || 'Failed to create session' 75 | return { success: false, error: error.value } 76 | } finally { 77 | loading.value = false 78 | } 79 | } 80 | 81 | const updateSession = async (sessionId, sessionData) => { 82 | loading.value = true 83 | error.value = null 84 | 85 | try { 86 | const response = await axios.put(`/api/sessions/${sessionId}`, sessionData) 87 | const updatedSession = { 88 | ...response.data.session, 89 | credentialId: response.data.session.credential_id // Map backend's credential_id 90 | } 91 | 92 | // Update in local sessions array 93 | const index = sessions.value.findIndex(s => s.id === sessionId) 94 | if (index !== -1) { 95 | sessions.value[index] = updatedSession 96 | } 97 | 98 | return { success: true, session: updatedSession } 99 | } catch (err) { 100 | error.value = err.response?.data?.error || 'Failed to update session' 101 | return { success: false, error: error.value } 102 | } finally { 103 | loading.value = false 104 | } 105 | } 106 | 107 | const deleteSession = async (sessionId) => { 108 | loading.value = true 109 | error.value = null 110 | 111 | try { 112 | await axios.delete(`/api/sessions/${sessionId}`) 113 | 114 | // Remove from local sessions array 115 | sessions.value = sessions.value.filter(s => s.id !== sessionId) 116 | 117 | return { success: true } 118 | } catch (err) { 119 | error.value = err.response?.data?.error || 'Failed to delete session' 120 | return { success: false, error: error.value } 121 | } finally { 122 | loading.value = false 123 | } 124 | } 125 | 126 | const duplicateSession = async (sessionId, newName) => { 127 | loading.value = true 128 | error.value = null 129 | 130 | try { 131 | const response = await axios.post(`/api/sessions/${sessionId}/duplicate`, { 132 | name: newName 133 | }) 134 | const duplicatedSession = response.data.session 135 | 136 | // Add to local sessions array 137 | sessions.value.unshift(duplicatedSession) 138 | 139 | return { success: true, session: duplicatedSession } 140 | } catch (err) { 141 | error.value = err.response?.data?.error || 'Failed to duplicate session' 142 | return { success: false, error: error.value } 143 | } finally { 144 | loading.value = false 145 | } 146 | } 147 | 148 | const testConnection = async (sessionId) => { 149 | loading.value = true 150 | error.value = null 151 | 152 | try { 153 | const response = await axios.post(`/api/sessions/${sessionId}/test`) 154 | return { success: true, result: response.data } 155 | } catch (err) { 156 | error.value = err.response?.data?.error || 'Connection test failed' 157 | return { 158 | success: false, 159 | error: error.value, 160 | details: err.response?.data?.details 161 | } 162 | } finally { 163 | loading.value = false 164 | } 165 | } 166 | 167 | const saveConsoleSnapshot = async (sessionId, snapshotData) => { 168 | loading.value = true 169 | error.value = null 170 | 171 | try { 172 | const response = await axios.post(`/api/sessions/${sessionId}/snapshot`, { 173 | snapshot: snapshotData 174 | }) 175 | return { success: true } 176 | } catch (err) { 177 | error.value = err.response?.data?.error || 'Failed to save console snapshot' 178 | return { success: false, error: error.value } 179 | } finally { 180 | loading.value = false 181 | } 182 | } 183 | 184 | const getSessionStats = async () => { 185 | try { 186 | const response = await axios.get('/api/sessions/stats/connections') 187 | return { success: true, stats: response.data.stats } 188 | } catch (err) { 189 | console.error('Failed to fetch session stats:', err) 190 | return { success: false } 191 | } 192 | } 193 | 194 | const findSessionById = (sessionId) => { 195 | return sessions.value.find(s => s.id === parseInt(sessionId)) 196 | } 197 | 198 | const getRecentSessions = (limit = 5) => { 199 | return sessions.value 200 | .sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at)) 201 | .slice(0, limit) 202 | } 203 | 204 | const searchSessions = (query) => { 205 | if (!query) return sessions.value 206 | 207 | const searchTerm = query.toLowerCase() 208 | return sessions.value.filter(session => 209 | session.name.toLowerCase().includes(searchTerm) || 210 | session.hostname.toLowerCase().includes(searchTerm) || 211 | session.username.toLowerCase().includes(searchTerm) 212 | ) 213 | } 214 | 215 | const clearError = () => { 216 | error.value = null 217 | } 218 | 219 | const clearCurrentSession = () => { 220 | currentSession.value = null 221 | } 222 | 223 | const setCurrentSession = (session) => { 224 | currentSession.value = session 225 | } 226 | 227 | // Validation helpers 228 | const validateSessionData = (sessionData) => { 229 | const errors = [] 230 | 231 | if (!sessionData.name?.trim()) { 232 | errors.push('Session name is required') 233 | } 234 | 235 | if (!sessionData.hostname?.trim()) { 236 | errors.push('Hostname is required') 237 | } 238 | 239 | if (!sessionData.username?.trim()) { 240 | errors.push('Username is required') 241 | } 242 | 243 | if (sessionData.port && (isNaN(sessionData.port) || sessionData.port < 1 || sessionData.port > 65535)) { 244 | errors.push('Port must be a valid number between 1 and 65535') 245 | } 246 | 247 | return { 248 | isValid: errors.length === 0, 249 | errors 250 | } 251 | } 252 | 253 | // Utility to forcibly clear loading state 254 | const clearLoadingState = () => { 255 | loading.value = false 256 | } 257 | 258 | return { 259 | // State 260 | sessions, 261 | currentSession, 262 | loading, 263 | error, 264 | 265 | // Getters 266 | allSessions, 267 | isLoading, 268 | sessionError, 269 | sessionCount, 270 | 271 | // Actions 272 | fetchSessions, 273 | getSession, 274 | createSession, 275 | updateSession, 276 | deleteSession, 277 | duplicateSession, 278 | testConnection, 279 | saveConsoleSnapshot, 280 | getSessionStats, 281 | findSessionById, 282 | getRecentSessions, 283 | searchSessions, 284 | clearError, 285 | clearCurrentSession, 286 | setCurrentSession, 287 | validateSessionData, 288 | clearLoadingState 289 | } 290 | }) 291 | -------------------------------------------------------------------------------- /client/src/assets/main.css: -------------------------------------------------------------------------------- 1 | /* XTerm CSS */ 2 | @import 'xterm/css/xterm.css'; 3 | 4 | @tailwind base; 5 | @tailwind components; 6 | @tailwind utilities; 7 | 8 | /* Base styles */ 9 | @layer base { 10 | html { 11 | @apply antialiased text-gray-900 dark:text-gray-100 transition-colors duration-200; 12 | font-feature-settings: "cv02", "cv03", "cv04", "cv11"; 13 | scroll-behavior: smooth; 14 | } 15 | 16 | body { 17 | @apply bg-slate-50 dark:bg-slate-900 transition-colors duration-200; 18 | } 19 | 20 | /* Improve focus styles for better accessibility */ 21 | :focus-visible { 22 | @apply outline-2 outline-offset-2 outline-indigo-500 dark:outline-indigo-400; 23 | } 24 | 25 | /* Default transition for interactive elements */ 26 | a, button, input, select, textarea { 27 | @apply transition-all duration-200; 28 | } 29 | 30 | /* Headings */ 31 | h1, h2, h3, h4, h5, h6 { 32 | @apply font-medium tracking-tight; 33 | } 34 | 35 | /* Code blocks */ 36 | code { 37 | @apply font-mono text-sm; 38 | } 39 | 40 | /* Make sure terminal text is crisp */ 41 | .xterm { 42 | font-smoothing: never; 43 | -webkit-font-smoothing: never; 44 | } 45 | } 46 | 47 | /* Components */ 48 | @layer components { 49 | /* Button styles */ 50 | .btn { 51 | @apply inline-flex items-center justify-center px-4 py-2 text-sm font-medium rounded-lg 52 | focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 53 | disabled:opacity-60 disabled:cursor-not-allowed 54 | shadow-sm transition-all duration-200; 55 | } 56 | 57 | .btn-primary { 58 | @apply btn bg-indigo-600 text-white hover:bg-indigo-700 active:bg-indigo-800 59 | focus-visible:ring-indigo-500 dark:bg-indigo-500 dark:hover:bg-indigo-600 60 | dark:active:bg-indigo-700 dark:focus-visible:ring-indigo-400; 61 | } 62 | 63 | .btn-secondary { 64 | @apply btn bg-slate-800 text-white hover:bg-slate-700 active:bg-slate-600 65 | focus-visible:ring-slate-500 dark:bg-slate-700 dark:hover:bg-slate-600 66 | dark:active:bg-slate-500 dark:focus-visible:ring-slate-400; 67 | } 68 | 69 | .btn-danger { 70 | @apply btn bg-red-600 text-white hover:bg-red-700 active:bg-red-800 71 | focus-visible:ring-red-500 dark:bg-red-500 dark:hover:bg-red-600 72 | dark:active:bg-red-700 dark:focus-visible:ring-red-400; 73 | } 74 | 75 | .btn-outline { 76 | @apply btn border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 77 | active:bg-slate-100 focus-visible:ring-slate-500 78 | dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300 79 | dark:hover:bg-slate-700 dark:active:bg-slate-600 dark:focus-visible:ring-slate-400; 80 | } 81 | 82 | .btn-ghost { 83 | @apply btn text-slate-700 hover:bg-slate-100 active:bg-slate-200 84 | dark:text-slate-300 dark:hover:bg-slate-800 dark:active:bg-slate-700; 85 | } 86 | 87 | /* Form elements */ 88 | .form-input { 89 | @apply block w-full px-3 py-2 text-sm bg-white border border-slate-300 rounded-lg 90 | shadow-sm placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 91 | focus:border-indigo-500 92 | dark:bg-slate-800 dark:border-slate-600 dark:placeholder-slate-500 93 | dark:text-slate-300 dark:focus:border-indigo-500; 94 | } 95 | 96 | .form-label { 97 | @apply block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1; 98 | } 99 | 100 | .form-error { 101 | @apply mt-1 text-sm text-red-600 dark:text-red-400; 102 | } 103 | 104 | /* Card components */ 105 | .card { 106 | @apply bg-white dark:bg-slate-800 overflow-hidden shadow-soft rounded-xl 107 | border border-slate-200 dark:border-slate-700 transition-all duration-200; 108 | } 109 | 110 | .card-header { 111 | @apply px-4 py-5 sm:px-6 border-b border-slate-200 dark:border-slate-700; 112 | } 113 | 114 | .card-body { 115 | @apply px-4 py-5 sm:p-6; 116 | } 117 | 118 | .card-footer { 119 | @apply px-4 py-4 sm:px-6 border-t border-slate-200 dark:border-slate-700; 120 | } 121 | 122 | /* Badge */ 123 | .badge { 124 | @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; 125 | } 126 | 127 | .badge-primary { 128 | @apply badge bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200; 129 | } 130 | 131 | .badge-secondary { 132 | @apply badge bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-200; 133 | } 134 | 135 | .badge-success { 136 | @apply badge bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200; 137 | } 138 | 139 | .badge-danger { 140 | @apply badge bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200; 141 | } 142 | 143 | .badge-warning { 144 | @apply badge bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200; 145 | } 146 | 147 | /* Panels and containers */ 148 | .content-container { 149 | @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; 150 | } 151 | 152 | .panel { 153 | @apply bg-white dark:bg-slate-800 rounded-xl shadow-soft p-4 sm:p-6; 154 | } 155 | } 156 | 157 | /* Utilities */ 158 | @layer utilities { 159 | .text-balance { 160 | text-wrap: balance; 161 | } 162 | 163 | /* Glass effect */ 164 | .bg-glass { 165 | @apply bg-white/80 dark:bg-slate-900/80 backdrop-blur-md border border-white/20 dark:border-slate-700/30; 166 | } 167 | 168 | /* Ring utilities */ 169 | .ring-focus { 170 | @apply ring-2 ring-indigo-500 ring-offset-2 dark:ring-indigo-400 dark:ring-offset-slate-900; 171 | } 172 | 173 | /* Shadow variants */ 174 | .shadow-soft { 175 | @apply shadow-[0_2px_10px_0_rgba(0,0,0,0.05)] dark:shadow-[0_2px_10px_0_rgba(0,0,0,0.3)]; 176 | } 177 | 178 | .shadow-button { 179 | @apply shadow-[0_1px_2px_0_rgba(0,0,0,0.05)] dark:shadow-[0_1px_3px_0_rgba(0,0,0,0.3)]; 180 | } 181 | } 182 | 183 | /* Terminal styles */ 184 | .terminal-container { 185 | @apply w-full h-full bg-terminal-bg dark:bg-terminal-bgDark transition-colors duration-200; 186 | } 187 | 188 | .terminal-container .xterm { 189 | @apply h-full; 190 | } 191 | 192 | .terminal-container .xterm-viewport { 193 | @apply bg-terminal-bg dark:bg-terminal-bgDark transition-colors duration-200; 194 | } 195 | 196 | .terminal-container .xterm-screen { 197 | @apply bg-terminal-bg dark:bg-terminal-bgDark transition-colors duration-200; 198 | } 199 | 200 | /* Custom scrollbar */ 201 | .custom-scrollbar { 202 | scrollbar-width: thin; 203 | scrollbar-color: theme('colors.slate.400') theme('colors.slate.100'); 204 | } 205 | 206 | .dark .custom-scrollbar { 207 | scrollbar-color: theme('colors.slate.600') theme('colors.slate.800'); 208 | } 209 | 210 | .custom-scrollbar::-webkit-scrollbar { 211 | @apply w-1.5; 212 | } 213 | 214 | .custom-scrollbar::-webkit-scrollbar-track { 215 | @apply bg-slate-100 dark:bg-slate-800 rounded-full; 216 | } 217 | 218 | .custom-scrollbar::-webkit-scrollbar-thumb { 219 | @apply bg-slate-400 dark:bg-slate-600 rounded-full hover:bg-slate-500 dark:hover:bg-slate-500 transition-colors duration-200; 220 | } 221 | 222 | /* Loading spinner */ 223 | .spinner { 224 | @apply inline-block w-4 h-4 border-2 border-current border-r-transparent rounded-full animate-spin text-indigo-500 dark:text-indigo-400; 225 | } 226 | 227 | /* Animation for fade transitions */ 228 | .fade-enter-active, 229 | .fade-leave-active { 230 | @apply transition-opacity duration-200; 231 | } 232 | 233 | .fade-enter-from, 234 | .fade-leave-to { 235 | @apply opacity-0; 236 | } 237 | 238 | /* Slide animations */ 239 | .slide-up-enter-active, 240 | .slide-up-leave-active { 241 | @apply transition-all duration-200 ease-out; 242 | } 243 | 244 | .slide-up-enter-from, 245 | .slide-up-leave-to { 246 | @apply transform translate-y-4 opacity-0; 247 | } 248 | 249 | /* Connection status indicator */ 250 | .status-indicator { 251 | @apply inline-block w-2.5 h-2.5 rounded-full transition-colors duration-200 relative; 252 | } 253 | 254 | .status-indicator::after { 255 | content: ''; 256 | @apply absolute inset-0 rounded-full animate-ping opacity-75; 257 | } 258 | 259 | .status-connected { 260 | @apply bg-green-500 dark:bg-green-400; 261 | } 262 | 263 | .status-connected::after { 264 | @apply bg-green-500 dark:bg-green-400; 265 | animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite; 266 | } 267 | 268 | .status-disconnected { 269 | @apply bg-red-500 dark:bg-red-400; 270 | } 271 | 272 | .status-connecting { 273 | @apply bg-amber-500 dark:bg-amber-400; 274 | } 275 | 276 | .status-connecting::after { 277 | @apply bg-amber-500 dark:bg-amber-400; 278 | animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; 279 | } 280 | 281 | /* Responsive utilities */ 282 | @media (max-width: 640px) { 283 | .mobile-hide { 284 | @apply hidden; 285 | } 286 | } 287 | 288 | /* Responsive typography */ 289 | @media (min-width: 768px) { 290 | html { 291 | font-size: 16.5px; 292 | } 293 | } 294 | 295 | @media (min-width: 1024px) { 296 | html { 297 | font-size: 17px; 298 | } 299 | } 300 | 301 | /* Print styles */ 302 | @media print { 303 | .no-print { 304 | @apply hidden; 305 | } 306 | 307 | body { 308 | @apply text-black bg-white; 309 | } 310 | } 311 | 312 | /* Dark mode toggle animation */ 313 | .dark-mode-toggle { 314 | @apply relative inline-flex h-6 w-11 items-center rounded-full cursor-pointer transition-colors duration-300 ease-in-out; 315 | } 316 | 317 | .dark-mode-toggle__handle { 318 | @apply inline-block h-5 w-5 transform rounded-full bg-white shadow-md transition-transform duration-300 ease-in-out; 319 | } 320 | 321 | .dark-mode-toggle__handle--day { 322 | @apply translate-x-5; 323 | } 324 | 325 | .dark-mode-toggle__handle--night { 326 | @apply translate-x-0; 327 | } 328 | 329 | .dark-mode-toggle__icon { 330 | @apply absolute top-1/2 -translate-y-1/2 text-yellow-400 dark:text-slate-300; 331 | } 332 | 333 | .dark-mode-toggle__icon--sun { 334 | @apply left-1; 335 | } 336 | 337 | .dark-mode-toggle__icon--moon { 338 | @apply right-1; 339 | } 340 | -------------------------------------------------------------------------------- /server/src/app.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const express = require('express'); 3 | const http = require('http'); 4 | const socketIo = require('socket.io'); 5 | const cors = require('cors'); 6 | const helmet = require('helmet'); 7 | const rateLimit = require('express-rate-limit'); 8 | const path = require('path'); 9 | 10 | // Import services and middleware 11 | const db = require('./db/database'); 12 | const { runMigration } = require('./db/migration'); 13 | 14 | // Load routes 15 | const authRoutes = require('./api/auth'); 16 | const sessionRoutes = require('./api/sessions'); 17 | const debugRoutes = require('./api/debug'); 18 | const settingsRoutes = require('./api/settings'); 19 | const filesRoutes = require('./api/files'); 20 | const credentialRoutes = require('./api/credentials'); 21 | const handleSocketConnection = require('./socket/terminal'); 22 | const { handleAuthError } = require('./middleware/authMiddleware'); 23 | 24 | // Initialize Express app 25 | const app = express(); 26 | const server = http.createServer(app); 27 | 28 | // Initialize Socket.IO with permissive CORS for LAN compatibility 29 | const io = socketIo(server, { 30 | cors: { 31 | origin: '*', 32 | methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], 33 | allowedHeaders: ["Content-Type", "Authorization"], 34 | credentials: false 35 | }, 36 | transports: ['websocket', 'polling'] 37 | }); 38 | 39 | // HELMET IS COMPLETELY DISABLED - NO SECURITY HEADERS 40 | console.log('Running with NO security headers - Helmet is completely disabled'); 41 | 42 | // CORS configuration - completely disabled for compatibility 43 | app.use((req, res, next) => { 44 | // Completely disable CORS restrictions 45 | res.header('Access-Control-Allow-Origin', '*'); 46 | res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS'); 47 | res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Length, X-Requested-With'); 48 | 49 | // Remove any security headers that might have been added elsewhere 50 | // Remove CSP headers 51 | res.removeHeader('Content-Security-Policy'); 52 | res.removeHeader('Content-Security-Policy-Report-Only'); 53 | 54 | // Remove HTTPS enforcement headers 55 | res.removeHeader('Strict-Transport-Security'); 56 | 57 | // Remove cross-origin restriction headers 58 | res.removeHeader('Cross-Origin-Opener-Policy'); 59 | res.removeHeader('Cross-Origin-Resource-Policy'); 60 | res.removeHeader('Cross-Origin-Embedder-Policy'); 61 | 62 | // Remove other security headers that might interfere 63 | res.removeHeader('X-Frame-Options'); 64 | res.removeHeader('X-XSS-Protection'); 65 | res.removeHeader('Origin-Agent-Cluster'); 66 | 67 | // Handle preflight requests 68 | if (req.method === 'OPTIONS') { 69 | res.status(200).send(); 70 | } else { 71 | next(); 72 | } 73 | }); 74 | 75 | /* 76 | // Rate limiting 77 | const generalLimiter = rateLimit({ 78 | windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes 79 | max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100, // limit each IP to 100 requests per windowMs 80 | message: { 81 | error: 'Too many requests from this IP, please try again later.' 82 | }, 83 | standardHeaders: true, 84 | legacyHeaders: false, 85 | }); 86 | */ 87 | 88 | // app.use(generalLimiter); 89 | 90 | // Body parsing middleware 91 | app.use(express.json({ limit: '10mb' })); 92 | app.use(express.urlencoded({ extended: true, limit: '10mb' })); 93 | 94 | // Request logging middleware 95 | app.use((req, res, next) => { 96 | const timestamp = new Date().toISOString(); 97 | console.log(`${timestamp} ${req.method} ${req.path} - ${req.ip}`); 98 | 99 | // Add response header logging 100 | const originalSend = res.send; 101 | res.send = function(...args) { 102 | // Log the headers being sent 103 | console.log(`${timestamp} Response headers for ${req.method} ${req.path}:`, JSON.stringify(res.getHeaders())); 104 | return originalSend.apply(res, args); 105 | }; 106 | 107 | next(); 108 | }); 109 | 110 | // Health check endpoint 111 | app.get('/health', (req, res) => { 112 | res.json({ 113 | status: 'OK', 114 | timestamp: new Date().toISOString(), 115 | uptime: process.uptime(), 116 | environment: process.env.NODE_ENV || 'development' 117 | }); 118 | }); 119 | 120 | // API routes 121 | app.use('/api/auth', authRoutes); 122 | app.use('/api/sessions', sessionRoutes); 123 | app.use('/api/ssh', debugRoutes); 124 | app.use('/api/settings', settingsRoutes); 125 | app.use('/api/files', filesRoutes); 126 | app.use('/api/credentials', credentialRoutes); 127 | 128 | // Handle 404 for API routes 129 | app.use('/api/*', (req, res) => { 130 | res.status(404).json({ 131 | error: 'API endpoint not found', 132 | path: req.path, 133 | method: req.method 134 | }); 135 | }); 136 | 137 | if (process.env.NODE_ENV === 'production') { 138 | let clientBuildPath = path.join(__dirname, '../public'); 139 | 140 | app.use(express.static(clientBuildPath)); 141 | 142 | app.get('*', (req, res) => { 143 | res.sendFile(path.join(clientBuildPath, 'index.html')); 144 | }); 145 | } else { 146 | let clientBuildPath = path.join(__dirname, '../../client/dist'); 147 | 148 | app.use(express.static(clientBuildPath)); 149 | 150 | app.get('*', (req, res) => { 151 | res.sendFile(path.join(clientBuildPath, 'index.html')); 152 | }); 153 | } 154 | 155 | // Auth error handling middleware 156 | app.use(handleAuthError); 157 | 158 | // Global error handling middleware 159 | app.use((error, req, res, next) => { 160 | console.error('Global error handler:', error); 161 | 162 | // Don't expose error details in production 163 | const isDevelopment = process.env.NODE_ENV === 'development'; 164 | 165 | res.status(error.status || 500).json({ 166 | error: isDevelopment ? error.message : 'Internal server error', 167 | ...(isDevelopment && { stack: error.stack }) 168 | }); 169 | }); 170 | 171 | // Socket.IO connection handling 172 | handleSocketConnection(io); 173 | 174 | // Initialize database and start server 175 | const PORT = process.env.PORT || 3000; 176 | 177 | const startServer = async () => { 178 | try { 179 | // Connect to database 180 | await db.connect(); 181 | console.log('Database connected successfully'); 182 | 183 | // Run database migrations 184 | await runMigration(); 185 | 186 | // Initialize services that need database settings 187 | console.log('Attempting to require encryptionService...'); 188 | const encryptionService = require('./services/encryptionService'); 189 | console.log('Attempting to require llmService...'); 190 | const llmService = require('./services/llmService'); 191 | console.log('Attempting to require sessionService...'); 192 | const sessionService = require('./services/sessionService'); 193 | 194 | console.log('Initializing services in parallel...'); 195 | await Promise.all([ 196 | (async () => { 197 | try { 198 | await encryptionService.init(); 199 | console.log('Encryption service initialized.'); 200 | } catch (e) { 201 | console.error('Error initializing encryption service:', e); 202 | throw e; 203 | } 204 | })(), 205 | (async () => { 206 | try { 207 | await llmService.init(); 208 | console.log('LLM service initialized.'); 209 | } catch (e) { 210 | console.error('Error initializing LLM service:', e); 211 | throw e; 212 | } 213 | })(), 214 | (async () => { 215 | try { 216 | await sessionService.init(); 217 | console.log('Session service initialized.'); 218 | } catch (e) { 219 | console.error('Error initializing session service:', e); 220 | throw e; 221 | } 222 | })() 223 | ]); 224 | 225 | console.log('Services initialized with database settings'); 226 | console.log('NOTE: LLM service is initialized with global settings at startup.'); 227 | console.log(' User-specific settings will be loaded for each connection.'); 228 | 229 | // Start server 230 | server.listen(PORT, () => { 231 | console.log(` 232 | ╔══════════════════════════════════════════╗ 233 | ║ IntelliSSH Server ║ 234 | ║ ║ 235 | ║ 🚀 Server running on port ${PORT} ║ 236 | ║ 📡 Environment: ${process.env.NODE_ENV || 'development'} ║ 237 | ║ 🔗 WebSocket: Enabled ║ 238 | ║ 🛡️ Security: Enabled ║ 239 | ║ ║ 240 | ║ API Endpoints: ║ 241 | ║ • /api/auth - Authentication ║ 242 | ║ • /api/sessions - Session Management ║ 243 | ║ • /api/ssh - SSH Debug Tools ║ 244 | ║ • /api/files - File Management ║ 245 | ║ • /health - Health Check ║ 246 | ║ ║ 247 | ╚══════════════════════════════════════════╝ 248 | `); 249 | }); 250 | 251 | } catch (error) { 252 | console.error('Failed to start server:', error); 253 | process.exit(1); 254 | } 255 | }; 256 | 257 | // Graceful shutdown handling 258 | const gracefulShutdown = async (signal) => { 259 | console.log(`\nReceived ${signal}. Starting graceful shutdown...`); 260 | 261 | try { 262 | // Close server 263 | server.close(() => { 264 | console.log('HTTP server closed'); 265 | }); 266 | 267 | // Close database connection 268 | await db.close(); 269 | console.log('Database connection closed'); 270 | 271 | console.log('Graceful shutdown completed'); 272 | process.exit(0); 273 | } catch (error) { 274 | console.error('Error during graceful shutdown:', error); 275 | process.exit(1); 276 | } 277 | }; 278 | 279 | // Handle process signals 280 | process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); 281 | process.on('SIGINT', () => gracefulShutdown('SIGINT')); 282 | 283 | // Handle uncaught exceptions 284 | process.on('uncaughtException', (error) => { 285 | console.error('Uncaught Exception:', error); 286 | gracefulShutdown('uncaughtException'); 287 | }); 288 | 289 | process.on('unhandledRejection', (reason, promise) => { 290 | console.error('Unhandled Rejection at:', promise, 'reason:', reason); 291 | gracefulShutdown('unhandledRejection'); 292 | }); 293 | 294 | // Start the server 295 | startServer(); 296 | 297 | module.exports = { app, server, io }; 298 | -------------------------------------------------------------------------------- /client/src/views/SshDebugView.vue: -------------------------------------------------------------------------------- 1 | 157 | 158 | 216 | 217 | 254 | -------------------------------------------------------------------------------- /client/src/components/CredentialFormModal.vue: -------------------------------------------------------------------------------- 1 | 97 | 98 | 211 | 212 | 253 | -------------------------------------------------------------------------------- /server/src/api/sessions.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const sessionService = require('../services/sessionService'); 3 | const sshService = require('../services/sshService'); 4 | const { authenticateToken } = require('../middleware/authMiddleware'); 5 | 6 | const router = express.Router(); 7 | 8 | // All routes require authentication 9 | router.use(authenticateToken); 10 | 11 | // @route GET /api/sessions 12 | // @desc Get all sessions for the authenticated user 13 | // @access Private 14 | router.get('/', async (req, res) => { 15 | try { 16 | const sessions = await sessionService.getSessionsByUserId(req.user.id); 17 | 18 | res.json({ 19 | success: true, 20 | sessions: sessions 21 | }); 22 | } catch (error) { 23 | console.error('Get sessions error:', error.message); 24 | res.status(500).json({ 25 | error: 'Internal server error while fetching sessions.' 26 | }); 27 | } 28 | }); 29 | 30 | // @route GET /api/sessions/:id 31 | // @desc Get a specific session by ID 32 | // @access Private 33 | router.get('/:id', async (req, res) => { 34 | try { 35 | const sessionId = parseInt(req.params.id); 36 | 37 | if (isNaN(sessionId)) { 38 | return res.status(400).json({ 39 | error: 'Invalid session ID.' 40 | }); 41 | } 42 | 43 | const session = await sessionService.getSessionById(sessionId, req.user.id); 44 | 45 | res.json({ 46 | success: true, 47 | session: session 48 | }); 49 | } catch (error) { 50 | console.error('Get session error:', error.message); 51 | 52 | if (error.message === 'Session not found') { 53 | return res.status(404).json({ 54 | error: 'Session not found.' 55 | }); 56 | } 57 | 58 | res.status(500).json({ 59 | error: 'Internal server error while fetching session.' 60 | }); 61 | } 62 | }); 63 | 64 | // @route POST /api/sessions 65 | // @desc Create a new session 66 | // @access Private 67 | router.post('/', async (req, res) => { 68 | try { 69 | // Initialize session service to ensure encryption key is loaded 70 | await sessionService.init(); 71 | 72 | const sessionData = { 73 | name: req.body.name, 74 | hostname: req.body.hostname, 75 | port: req.body.port, 76 | username: req.body.username, 77 | password: req.body.password, 78 | privateKey: req.body.privateKey, 79 | keyPassphrase: req.body.keyPassphrase, 80 | credentialId: req.body.credentialId 81 | }; 82 | 83 | // Validate session data 84 | await sessionService.validateSessionData(sessionData); 85 | 86 | const session = await sessionService.createSession(req.user.id, sessionData); 87 | 88 | res.status(201).json({ 89 | success: true, 90 | message: 'Session created successfully.', 91 | session: session 92 | }); 93 | } catch (error) { 94 | console.error('Create session error:', error.message); 95 | 96 | if (error.message.includes('required') || error.message.includes('valid')) { 97 | return res.status(400).json({ 98 | error: error.message 99 | }); 100 | } 101 | 102 | res.status(500).json({ 103 | error: 'Internal server error while creating session.' 104 | }); 105 | } 106 | }); 107 | 108 | // @route PUT /api/sessions/:id 109 | // @desc Update a session 110 | // @access Private 111 | router.put('/:id', async (req, res) => { 112 | try { 113 | const sessionId = parseInt(req.params.id); 114 | 115 | if (isNaN(sessionId)) { 116 | return res.status(400).json({ 117 | error: 'Invalid session ID.' 118 | }); 119 | } 120 | 121 | const updateData = { 122 | name: req.body.name, 123 | hostname: req.body.hostname, 124 | port: req.body.port, 125 | username: req.body.username, 126 | password: req.body.password, 127 | privateKey: req.body.privateKey, 128 | keyPassphrase: req.body.keyPassphrase, 129 | credentialId: req.body.credentialId 130 | }; 131 | 132 | // Validate session data 133 | await sessionService.validateSessionData(updateData); 134 | 135 | const session = await sessionService.updateSession(sessionId, req.user.id, updateData); 136 | 137 | res.json({ 138 | success: true, 139 | message: 'Session updated successfully.', 140 | session: session 141 | }); 142 | } catch (error) { 143 | console.error('Update session error:', error.message); 144 | 145 | if (error.message === 'Session not found') { 146 | return res.status(404).json({ 147 | error: 'Session not found.' 148 | }); 149 | } 150 | 151 | if (error.message.includes('required') || error.message.includes('valid')) { 152 | return res.status(400).json({ 153 | error: error.message 154 | }); 155 | } 156 | 157 | res.status(500).json({ 158 | error: 'Internal server error while updating session.' 159 | }); 160 | } 161 | }); 162 | 163 | // @route DELETE /api/sessions/:id 164 | // @desc Delete a session 165 | // @access Private 166 | router.delete('/:id', async (req, res) => { 167 | try { 168 | const sessionId = parseInt(req.params.id); 169 | 170 | if (isNaN(sessionId)) { 171 | return res.status(400).json({ 172 | error: 'Invalid session ID.' 173 | }); 174 | } 175 | 176 | const result = await sessionService.deleteSession(sessionId, req.user.id); 177 | 178 | res.json({ 179 | success: true, 180 | message: result.message 181 | }); 182 | } catch (error) { 183 | console.error('Delete session error:', error.message); 184 | 185 | if (error.message === 'Session not found') { 186 | return res.status(404).json({ 187 | error: 'Session not found.' 188 | }); 189 | } 190 | 191 | res.status(500).json({ 192 | error: 'Internal server error while deleting session.' 193 | }); 194 | } 195 | }); 196 | 197 | // @route POST /api/sessions/:id/duplicate 198 | // @desc Duplicate a session 199 | // @access Private 200 | router.post('/:id/duplicate', async (req, res) => { 201 | try { 202 | const sessionId = parseInt(req.params.id); 203 | 204 | if (isNaN(sessionId)) { 205 | return res.status(400).json({ 206 | error: 'Invalid session ID.' 207 | }); 208 | } 209 | 210 | const newName = req.body.name; 211 | const session = await sessionService.duplicateSession(sessionId, req.user.id, newName); 212 | 213 | res.status(201).json({ 214 | success: true, 215 | message: 'Session duplicated successfully.', 216 | session: session 217 | }); 218 | } catch (error) { 219 | console.error('Duplicate session error:', error.message); 220 | 221 | if (error.message === 'Session not found') { 222 | return res.status(404).json({ 223 | error: 'Session not found.' 224 | }); 225 | } 226 | 227 | res.status(500).json({ 228 | error: 'Internal server error while duplicating session.' 229 | }); 230 | } 231 | }); 232 | 233 | // @route POST /api/sessions/:id/snapshot 234 | // @desc Save console snapshot for a session 235 | // @access Private 236 | router.post('/:id/snapshot', async (req, res) => { 237 | try { 238 | const sessionId = parseInt(req.params.id); 239 | 240 | if (isNaN(sessionId)) { 241 | return res.status(400).json({ 242 | error: 'Invalid session ID.' 243 | }); 244 | } 245 | 246 | const { snapshot } = req.body; 247 | 248 | if (!snapshot) { 249 | return res.status(400).json({ 250 | error: 'Console snapshot data is required.' 251 | }); 252 | } 253 | 254 | const result = await sessionService.saveConsoleSnapshot(sessionId, req.user.id, snapshot); 255 | 256 | res.json({ 257 | success: true, 258 | message: 'Console snapshot saved successfully' 259 | }); 260 | } catch (error) { 261 | console.error('Save console snapshot error:', error.message); 262 | 263 | if (error.message === 'Session not found') { 264 | return res.status(404).json({ 265 | error: 'Session not found.' 266 | }); 267 | } 268 | 269 | res.status(500).json({ 270 | error: 'Internal server error while saving console snapshot.' 271 | }); 272 | } 273 | }); 274 | 275 | // @route POST /api/sessions/:id/test 276 | // @desc Test SSH connection for a session 277 | // @access Private 278 | router.post('/:id/test', async (req, res) => { 279 | try { 280 | const sessionId = parseInt(req.params.id); 281 | 282 | if (isNaN(sessionId)) { 283 | return res.status(400).json({ 284 | error: 'Invalid session ID.' 285 | }); 286 | } 287 | 288 | // Get session with credentials for testing 289 | const sessionData = await sessionService.getSessionWithCredentials(sessionId, req.user.id); 290 | 291 | // Test the connection 292 | const result = await sshService.testConnection(sessionData); 293 | 294 | res.json({ 295 | success: true, 296 | message: result.message, 297 | connectionTest: { 298 | hostname: sessionData.hostname, 299 | port: sessionData.port, 300 | username: sessionData.username, 301 | success: result.success 302 | } 303 | }); 304 | } catch (error) { 305 | console.error('Test connection error:', error.message); 306 | 307 | if (error.message === 'Session not found') { 308 | return res.status(404).json({ 309 | error: 'Session not found.' 310 | }); 311 | } 312 | 313 | // SSH connection errors 314 | if (error.message.includes('timeout') || error.message.includes('ECONNREFUSED')) { 315 | return res.status(400).json({ 316 | error: 'Connection failed: Unable to reach the host.', 317 | details: error.message 318 | }); 319 | } 320 | 321 | if (error.message.includes('Authentication failed')) { 322 | return res.status(400).json({ 323 | error: 'Connection failed: Authentication failed.', 324 | details: error.message 325 | }); 326 | } 327 | 328 | res.status(500).json({ 329 | error: 'Connection test failed.', 330 | details: error.message 331 | }); 332 | } 333 | }); 334 | 335 | // @route GET /api/sessions/stats 336 | // @desc Get connection statistics 337 | // @access Private 338 | router.get('/stats/connections', async (req, res) => { 339 | try { 340 | const stats = sshService.getStats(); 341 | 342 | // Filter connections to only show user's connections 343 | const userConnections = stats.connections.filter(conn => { 344 | // This would need to be enhanced to properly filter by user 345 | // For now, we'll return basic stats 346 | return true; 347 | }); 348 | 349 | res.json({ 350 | success: true, 351 | stats: { 352 | totalConnections: userConnections.length, 353 | connections: userConnections 354 | } 355 | }); 356 | } catch (error) { 357 | console.error('Get stats error:', error.message); 358 | res.status(500).json({ 359 | error: 'Internal server error while fetching statistics.' 360 | }); 361 | } 362 | }); 363 | 364 | module.exports = router; 365 | -------------------------------------------------------------------------------- /client/src/views/LoginView.vue: -------------------------------------------------------------------------------- 1 | 137 | 138 | 278 | -------------------------------------------------------------------------------- /server/src/db/database.js: -------------------------------------------------------------------------------- 1 | const sqlite3 = require('sqlite3').verbose(); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | 5 | class Database { 6 | constructor() { 7 | this.db = null; 8 | } 9 | 10 | async connect() { 11 | const dbPath = process.env.DB_PATH || './data/webssh.db'; 12 | const dbDir = path.dirname(dbPath); 13 | 14 | // Create data directory if it doesn't exist 15 | if (!fs.existsSync(dbDir)) { 16 | fs.mkdirSync(dbDir, { recursive: true }); 17 | } 18 | 19 | return new Promise((resolve, reject) => { 20 | this.db = new sqlite3.Database(dbPath, (err) => { 21 | if (err) { 22 | console.error('Error opening database:', err.message); 23 | reject(err); 24 | } else { 25 | console.log('Connected to SQLite database.'); 26 | this.initializeTables() 27 | .then(() => this.insertDefaultSettings()) 28 | .then(() => this.createAdminUserIfNeeded()) 29 | .then(resolve) 30 | .catch(reject); 31 | } 32 | }); 33 | }); 34 | } 35 | 36 | async insertDefaultSettings() { 37 | // Default settings from .env.example values 38 | const defaultSettings = [ 39 | // LLM Helper settings 40 | { id: 'llm_provider', name: 'LLM Provider', value: 'openai', category: 'llm', description: 'LLM provider (openai or ollama)', is_sensitive: 0 }, 41 | { id: 'openai_api_key', name: 'OpenAI API Key', value: '', category: 'llm', description: 'API key for OpenAI', is_sensitive: 1 }, 42 | { id: 'openai_model', name: 'OpenAI Model', value: 'gpt-3.5-turbo', category: 'llm', description: 'Model name for OpenAI', is_sensitive: 0 }, 43 | { id: 'ollama_url', name: 'Ollama URL', value: 'http://localhost:11434', category: 'llm', description: 'URL for Ollama API', is_sensitive: 0 }, 44 | { id: 'ollama_model', name: 'Ollama Model', value: 'llama2', category: 'llm', description: 'Model name for Ollama', is_sensitive: 0 }, 45 | 46 | // Encryption settings 47 | { id: 'encryption_key', name: 'Encryption Key', value: '736f4149702aae82ab6e45e64d977e3c6c1e9f7b29b368f61cafab1b9c2cc3b2', category: 'security', description: 'Encryption key for sensitive data', is_sensitive: 1 }, 48 | 49 | // Server settings 50 | { id: 'cors_origin', name: 'CORS Origin', value: 'http://localhost:8080', category: 'server', description: 'Allowed CORS origin', is_sensitive: 0 }, 51 | { id: 'rate_limit_window_ms', name: 'Rate Limit Window', value: '900000', category: 'server', description: 'Rate limit window in milliseconds', is_sensitive: 0 }, 52 | { id: 'rate_limit_max_requests', name: 'Rate Limit Max Requests', value: '100', category: 'server', description: 'Maximum requests per rate limit window', is_sensitive: 0 }, 53 | { id: 'site_name', name: 'Site Name', value: 'IntelliSSH', category: 'server', description: 'Name of the site for emails and UI', is_sensitive: 0 }, 54 | 55 | // Authentication settings (admin only - global server settings) 56 | { id: 'jwt_expires_in', name: 'JWT Expiration', value: '24h', category: 'server', description: 'JWT token expiration time', is_sensitive: 0 }, 57 | 58 | // Registration control (admin only - global server settings) 59 | { id: 'registration_enabled', name: 'Enable Registration', value: 'true', category: 'server', description: 'Allow new users to register', is_sensitive: 0 }, 60 | 61 | // Email settings 62 | { id: 'smtp_host', name: 'SMTP Host', value: '', category: 'email', description: 'SMTP server hostname', is_sensitive: 0 }, 63 | { id: 'smtp_port', name: 'SMTP Port', value: '587', category: 'email', description: 'SMTP server port', is_sensitive: 0 }, 64 | { id: 'smtp_user', name: 'SMTP Username', value: '', category: 'email', description: 'SMTP server username', is_sensitive: 0 }, 65 | { id: 'smtp_password', name: 'SMTP Password', value: '', category: 'email', description: 'SMTP server password', is_sensitive: 1 }, 66 | { id: 'email_from', name: 'From Email', value: 'noreply@webssh.example.com', category: 'email', description: 'Email address used as sender', is_sensitive: 0 } 67 | ]; 68 | 69 | // Insert each setting 70 | for (const setting of defaultSettings) { 71 | const existing = await this.get('SELECT id FROM settings WHERE id = ?', [setting.id]); 72 | 73 | if (!existing) { 74 | await this.run( 75 | 'INSERT INTO settings (id, name, value, category, description, is_sensitive) VALUES (?, ?, ?, ?, ?, ?)', 76 | [setting.id, setting.name, setting.value, setting.category, setting.description, setting.is_sensitive] 77 | ); 78 | } 79 | } 80 | console.log('Default settings initialized.'); 81 | } 82 | 83 | async createAdminUserIfNeeded() { 84 | const adminUser = await this.get('SELECT * FROM users WHERE role = "admin"'); 85 | 86 | if (!adminUser) { 87 | console.log('No admin user found. Creating initial admin account...'); 88 | 89 | // Check if there are any users at all 90 | const userCount = await this.get('SELECT COUNT(*) as count FROM users'); 91 | 92 | if (userCount.count === 0) { 93 | // This is a fresh installation, create admin account 94 | const bcrypt = require('bcrypt'); 95 | const saltRounds = 12; 96 | 97 | // Generate a secure random password if no admin exists 98 | const crypto = require('crypto'); 99 | const generatedPassword = crypto.randomBytes(8).toString('hex'); 100 | const hashedPassword = await bcrypt.hash(generatedPassword, saltRounds); 101 | 102 | await this.run( 103 | 'INSERT INTO users (username, password, role) VALUES (?, ?, ?)', 104 | ['admin', hashedPassword, 'admin'] 105 | ); 106 | 107 | console.log(` 108 | ======================================================== 109 | INITIAL ADMIN ACCOUNT CREATED 110 | Username: admin 111 | Password: ${generatedPassword} 112 | Please log in and change this password immediately! 113 | ======================================================== 114 | `); 115 | } else { 116 | // There are existing users but no admin 117 | // Let's promote the first user to admin 118 | await this.run('UPDATE users SET role = "admin" WHERE id = (SELECT MIN(id) FROM users)'); 119 | const promotedUser = await this.get('SELECT username FROM users WHERE role = "admin"'); 120 | console.log(`Promoted user '${promotedUser.username}' to admin role.`); 121 | } 122 | } else { 123 | console.log(`Admin user '${adminUser.username}' already exists.`); 124 | } 125 | } 126 | 127 | async initializeTables() { 128 | const createUsersTable = ` 129 | CREATE TABLE IF NOT EXISTS users ( 130 | id INTEGER PRIMARY KEY AUTOINCREMENT, 131 | username TEXT UNIQUE NOT NULL, 132 | password TEXT NOT NULL, 133 | email TEXT, 134 | role TEXT DEFAULT "user", 135 | reset_token TEXT, 136 | reset_token_expires DATETIME, 137 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP 138 | ) 139 | `; 140 | 141 | const createSessionsTable = ` 142 | CREATE TABLE IF NOT EXISTS sessions ( 143 | id INTEGER PRIMARY KEY AUTOINCREMENT, 144 | user_id INTEGER NOT NULL, 145 | name TEXT NOT NULL, 146 | hostname TEXT NOT NULL, 147 | port INTEGER DEFAULT 22, 148 | username TEXT NOT NULL, 149 | password TEXT, 150 | private_key TEXT, 151 | key_passphrase TEXT, 152 | iv TEXT, 153 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 154 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 155 | console_snapshot TEXT, 156 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE 157 | ) 158 | `; 159 | 160 | const createSettingsTable = ` 161 | CREATE TABLE IF NOT EXISTS settings ( 162 | id TEXT PRIMARY KEY, 163 | name TEXT NOT NULL, 164 | value TEXT NOT NULL, 165 | category TEXT NOT NULL, 166 | description TEXT, 167 | is_sensitive BOOLEAN DEFAULT 0, 168 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP 169 | ) 170 | `; 171 | 172 | const createUserSettingsTable = ` 173 | CREATE TABLE IF NOT EXISTS user_settings ( 174 | id INTEGER PRIMARY KEY AUTOINCREMENT, 175 | user_id INTEGER NOT NULL, 176 | setting_id TEXT NOT NULL, 177 | value TEXT NOT NULL, 178 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 179 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, 180 | FOREIGN KEY (setting_id) REFERENCES settings (id) ON DELETE CASCADE, 181 | UNIQUE(user_id, setting_id) 182 | ) 183 | `; 184 | 185 | return new Promise((resolve, reject) => { 186 | this.db.serialize(() => { 187 | this.db.run(createUsersTable, (err) => { 188 | if (err) { 189 | console.error('Error creating users table:', err.message); 190 | reject(err); 191 | return; 192 | } 193 | }); 194 | 195 | this.db.run(createSessionsTable, (err) => { 196 | if (err) { 197 | console.error('Error creating sessions table:', err.message); 198 | reject(err); 199 | return; 200 | } 201 | }); 202 | 203 | this.db.run(createSettingsTable, (err) => { 204 | if (err) { 205 | console.error('Error creating settings table:', err.message); 206 | reject(err); 207 | return; 208 | } 209 | }); 210 | 211 | this.db.run(createUserSettingsTable, (err) => { 212 | if (err) { 213 | console.error('Error creating user_settings table:', err.message); 214 | reject(err); 215 | return; 216 | } 217 | console.log('Database tables initialized.'); 218 | resolve(); 219 | }); 220 | }); 221 | }); 222 | } 223 | 224 | async run(sql, params = []) { 225 | return new Promise((resolve, reject) => { 226 | this.db.run(sql, params, function(err) { 227 | if (err) { 228 | reject(err); 229 | } else { 230 | resolve({ id: this.lastID, changes: this.changes }); 231 | } 232 | }); 233 | }); 234 | } 235 | 236 | async get(sql, params = []) { 237 | return new Promise((resolve, reject) => { 238 | this.db.get(sql, params, (err, row) => { 239 | if (err) { 240 | reject(err); 241 | } else { 242 | resolve(row); 243 | } 244 | }); 245 | }); 246 | } 247 | 248 | async all(sql, params = []) { 249 | return new Promise((resolve, reject) => { 250 | this.db.all(sql, params, (err, rows) => { 251 | if (err) { 252 | reject(err); 253 | } else { 254 | resolve(rows); 255 | } 256 | }); 257 | }); 258 | } 259 | 260 | close() { 261 | return new Promise((resolve, reject) => { 262 | if (this.db) { 263 | this.db.close((err) => { 264 | if (err) { 265 | reject(err); 266 | } else { 267 | console.log('Database connection closed.'); 268 | resolve(); 269 | } 270 | }); 271 | } else { 272 | resolve(); 273 | } 274 | }); 275 | } 276 | } 277 | 278 | module.exports = new Database(); 279 | -------------------------------------------------------------------------------- /client/src/views/ResetPasswordView.vue: -------------------------------------------------------------------------------- 1 | 154 | 155 | 252 | --------------------------------------------------------------------------------