Initial commit
Build and Push Multi-Arch Docker Image / build (push) Has been cancelled

This commit is contained in:
dido committed 2026-09-03 11:19:41 +03:00
commit 8ce01cb00a
34 files changed
+8346

No files matched your search

View File
Whitespace-only changes.
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""
StrixWebSSH Backend - Modular SSH and SFTP server.
This file is a compatibility wrapper around the StrixWebSSH backend package.
The actual implementation has been split into modular components in webssh_backend/:
- config.py: Configuration and constants
- models.py: SSHConfig dataclass
- database.py: CredentialsManager for SQLite operations
- recording.py: SessionRecorder for asciinema format
- auth.py: JWT authentication
- http_api.py: HTTP handlers (LoginHandler)
- ssh_handler.py: WebSocket to SSH bridge
- sftp_handler.py: WebSocket SFTP operations
- utils.py: Helper functions
- main.py: Entry point
For new development, use: python src/run.py
"""
import sys
# Import from the new modular package
from webssh_backend.main import main
from webssh_backend.config import logger
if __name__ == "__main__":
try:
import asyncio
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Server stopped by user")
except Exception as e:
logger.error(f"Fatal error: {e}")
sys.exit(1)
+388
View File
@@ -0,0 +1,388 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>StrixWebSSH - Help</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 10px;
text-align: center;
margin-bottom: 30px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.header h1 {
margin: 0;
font-size: 2.5em;
font-weight: 300;
}
.header p {
margin: 10px 0 0 0 0;
font-size: 1.1em;
opacity: 0.9;
}
.section {
background: white;
margin-bottom: 25px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.section-header {
background: #f8f9fa;
padding: 15px 20px;
border-bottom: 1px solid #e9ecef;
border-left: 4px solid #007bff;
}
.section-header h2 {
margin: 0;
color: #007bff;
font-size: 1.3em;
font-weight: 600;
}
.section-content {
padding: 20px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
padding: 15px;
border: 1px solid #e9ecef;
border-radius: 6px;
background: #f8f9fa;
}
.feature h3 {
margin: 0 0 10px 0;
color: #495057;
font-size: 1.1em;
}
.feature p {
margin: 0;
color: #6c757d;
font-size: 0.9em;
}
.step-list {
counter-reset: step-counter;
list-style: none;
padding: 0;
}
.step-list li {
counter-increment: step-counter;
margin-bottom: 15px;
position: relative;
padding-left: 50px;
}
.step-list li:before {
content: counter(step-counter);
position: absolute;
left: 0;
top: 0;
background: #007bff;
color: white;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.9em;
}
.keyboard-shortcuts {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 6px;
padding: 15px;
margin-top: 15px;
}
.keyboard-shortcuts h4 {
margin: 0 0 10px 0;
color: #856404;
}
.shortcut-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.shortcut {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: white;
border-radius: 4px;
border: 1px solid #ddd;
}
.shortcut kbd {
background: #f1f3f4;
border: 1px solid #d6d8dc;
border-radius: 3px;
padding: 2px 6px;
font-family: monospace;
font-size: 0.8em;
}
.troubleshooting {
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 6px;
padding: 15px;
margin-top: 15px;
}
.troubleshooting h4 {
margin: 0 0 10px 0;
color: #721c24;
}
.troubleshooting ul {
margin: 10px 0 0 0 20px;
color: #721c24;
}
.footer {
text-align: center;
margin-top: 40px;
padding: 20px;
color: #6c757d;
border-top: 1px solid #e9ecef;
}
.icon {
display: inline-block;
width: 20px;
height: 20px;
margin-right: 8px;
vertical-align: middle;
}
</style>
</head>
<body>
<div class="header">
<h1>🔐 StrixWebSSH</h1>
<p>Secure SSH client that runs entirely in your browser</p>
</div>
<div class="section">
<div class="section-header">
<h2>🌟 Key Features</h2>
</div>
<div class="section-content">
<div class="feature-grid">
<div class="feature">
<h3>🔐 Secure Authentication</h3>
<p>JWT-based login with configurable session timeout and encrypted storage of passwords and SSH keys.</p>
</div>
<div class="feature">
<h3>🌐 Multiple Connections</h3>
<p>Connect to multiple SSH servers simultaneously with easy switching between them.</p>
</div>
<div class="feature">
<h3>📁 SFTP File Manager</h3>
<p>Full-featured file manager with drag & drop upload, download, and file operations.</p>
</div>
<div class="feature">
<h3>💾 Persistent Data</h3>
<p>Encrypted storage of credentials across container restarts.</p>
</div>
<div class="feature">
<h3>📊 Upload Limits</h3>
<p>Configurable maximum file size (supports formats like "30M", "100M", "1G").</p>
</div>
<div class="feature">
<h3>🎥 Session Recording</h3>
<p>Automatic recording of SSH sessions in asciinema format with playback and configurable retention period.</p>
</div>
<div class="feature">
<h3>🎨 Modern Interface</h3>
<p>Responsive design with dark/light theme and intuitive controls.</p>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>🚀 Quick Start</h2>
</div>
<div class="section-content">
<ol class="step-list">
<li><strong>Open</strong> your browser and go to <code>http://localhost:8080</code></li>
<li><strong>Login</strong> with username and password (default: admin/admin)</li>
<li><strong>Create a connection</strong> by clicking "+ New Connection" and entering SSH server details</li>
<li><strong>Connect</strong> by clicking on the created connection</li>
<li><strong>Open terminal</strong> and start working with the remote server</li>
<li><strong>Use SFTP</strong> for file operations by clicking the "Files" tab</li>
</ol>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>🎥 Session Recordings</h2>
</div>
<div class="section-content">
<p>All SSH sessions are automatically recorded in asciinema format. To view recordings:</p>
<ol class="step-list">
<li><strong>Click "Recordings"</strong> button in the left sidebar</li>
<li><strong>Browse the list</strong> of recorded sessions (shows connection name, date, and duration)</li>
<li><strong>Click ▶ Play</strong> to replay the recording in the embedded player</li>
<li><strong>Download</strong> the recording as .cast.gz file for local storage</li>
<li><strong>Delete</strong> old recordings to free up space</li>
</ol>
<p class="text-muted">Recordings are kept for a configurable period (default 30 days) and automatically deleted after that.</p>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>⌨️ Keyboard Shortcuts</h2>
</div>
<div class="section-content">
<div class="keyboard-shortcuts">
<h4>🖥️ Terminal:</h4>
<div class="shortcut-list">
<div class="shortcut">
<span>Copy</span>
<kbd>Ctrl+C</kbd>
</div>
<div class="shortcut">
<span>Paste</span>
<kbd>Ctrl+V</kbd>
</div>
<div class="shortcut">
<span>New Terminal</span>
<kbd>Ctrl+T</kbd>
</div>
<div class="shortcut">
<span>Close Tab</span>
<kbd>Ctrl+W</kbd>
</div>
</div>
<h4>📁 SFTP File Manager:</h4>
<div class="shortcut-list">
<div class="shortcut">
<span>Refresh</span>
<kbd>F5</kbd>
</div>
<div class="shortcut">
<span>Delete</span>
<kbd>Delete</kbd>
</div>
<div class="shortcut">
<span>Rename</span>
<kbd>F2</kbd>
</div>
<div class="shortcut">
<span>Upload</span>
<kbd>Ctrl+U</kbd>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>🔧 Configuration</h2>
</div>
<div class="section-content">
<p><strong>MAX_UPLOAD_SIZE:</strong> Maximum file size (supports formats):</p>
<ul>
<li><code>"50M"</code> - 50 megabytes</li>
<li><code>"100M"</code> - 100 megabytes</li>
<li><code>"1G"</code> - 1 gigabyte</li>
<li><code>"500K"</code> - 500 kilobytes</li>
</ul>
<p><strong>STRIXWEBSSH_USERS:</strong> Users in format <code>username:password,username2:password2</code></p>
<p><strong>JWT_SECRET:</strong> Secret key for JWT tokens (auto-generated if not set)</p>
<p><strong>RECORDING_ENABLED:</strong> Enable/disable session recording (<code>true</code> or <code>false</code>)</p>
<p><strong>RECORDING_RETENTION_DAYS:</strong> Number of days to keep recordings (default: 30)</p>
<h3>🔒 Auto-Lock & Auto-Disconnect</h3>
<p>StrixWebSSH can automatically lock sessions after a period of inactivity and require your StrixWebSSH password to unlock. Sessions can also be automatically disconnected after a longer period.</p>
<ul>
<li><strong>Lock after:</strong> Period of inactivity before locking (5, 15, 30, 60 minutes)</li>
<li><strong>Disconnect after:</strong> Period of inactivity before disconnecting (30, 60 minutes, or never)</li>
<li><strong>Configuration:</strong> Set per connection in the connection settings</li>
<li><strong>Security:</strong> Lock requires StrixWebSSH password only (not SSH password)</li>
</ul>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>🛠️ Troubleshooting</h2>
</div>
<div class="section-content">
<div class="troubleshooting">
<h4>⚠️ Common Issues:</h4>
<ul>
<li><strong>Connection refused:</strong> Check if SSH server is running and port is open</li>
<li><strong>Authentication failed:</strong> Check username and password</li>
<li><strong>File too large:</strong> Increase MAX_UPLOAD_SIZE in docker-compose.yml</li>
<li><strong>401 Unauthorized:</strong> Refresh the page and login again</li>
<li><strong>Slow connection:</strong> Check internet connection and network settings</li>
</ul>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>🔒 Security</h2>
</div>
<div class="section-content">
<p><strong>Encryption:</strong> All passwords and SSH keys are encrypted with AES-128 (Fernet).</p>
<p><strong>JWT Tokens:</strong> Sessions are managed with JWT tokens with configurable timeout.</p>
<p><strong>Docker Isolation:</strong> The application runs in an isolated Docker environment.</p>
<p><strong>HTTPS:</strong> Recommended to use HTTPS in production environment.</p>
</div>
</div>
<div class="footer">
<p>📧 <strong>Support:</strong> For more information and help, check the project documentation.</p>
<p>🔄 <strong>Version:</strong> StrixWebSSH Multi-Connection Terminal v0.3.1</p>
<p>⚡ <strong>Technologies:</strong> Python, Paramiko, WebSockets, HTML5, CSS3, JavaScript</p>
</div>
</body>
</html>
+341
View File
@@ -0,0 +1,341 @@
<!DOCTYPE html>
<html lang="bg">
<head>
<meta charset="UTF-8">
<title>StrixWebSSH - Multi-Connection Terminal</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css" />
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
<!-- Asciinema Player -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/asciinema-player@3/dist/bundle/asciinema-player.css" />
<script src="https://cdn.jsdelivr.net/npm/asciinema-player@3/dist/bundle/asciinema-player.min.js"></script>
<!-- Pako for gzip decompression -->
<script src="https://cdn.jsdelivr.net/npm/pako@2/dist/pako.min.js"></script>
<link rel="stylesheet" href="static/webssh.css" />
</head>
<body>
<!-- Login Modal -->
<div class="modal-overlay active" id="loginModal">
<div class="modal">
<h3>StrixWebSSH Login</h3>
<div class="form-group">
<label>Username</label>
<input id="loginUsername" placeholder="admin">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="loginPassword" placeholder="Password">
</div>
<div id="loginError" style="color: #ef4444; font-size: 13px; margin-bottom: 10px;"></div>
<div class="modal-actions">
<button onclick="login()">Login</button>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-header">
<h2>Connections</h2>
<div style="display: flex; gap: 5px; margin-bottom: 10px;">
<span id="loggedInUser" style="font-size: 12px; color: #10b981;"></span>
<button class="btn-small" onclick="logout()" style="background: #374151; color: #9ca3af;">Logout</button>
</div>
<button class="btn-add" onclick="showNewConnectionModal()">+ New Connection</button>
<button class="btn-recordings" onclick="showRecordings()" title="Session Recordings">🎥 Recordings</button>
</div>
<div class="connection-list" id="connectionList">
</div>
</div>
<!-- Main Content -->
<div class="main-content">
<!-- Terminal View -->
<div id="terminalView" class="terminal-container">
<div class="terminal-header">
<span class="terminal-title" id="terminalTitle">Terminal</span>
<span class="terminal-status disconnected" id="terminalStatus">Disconnected</span>
</div>
<div id="terminal"></div>
</div>
<!-- SFTP View (File Manager) - Hidden by default -->
<div id="sftpView" class="sftp-container-main" style="display: none;">
<!-- Quick Connect Bar -->
<div class="sftp-quick-connect">
<div class="sftp-connect-field">
<label>Local:</label>
<span id="sftpLocalPathDisplay">Local Files</span>
</div>
<div class="sftp-connect-buttons">
<button class="btn-small" onclick="sftpRefresh()">🔄 Refresh</button>
<button class="btn-small" onclick="sftpMkdir()">📂 New Folder</button>
<button class="btn-small" onclick="sftpRename()">✏️ Rename</button>
<button class="btn-small btn-danger" onclick="sftpDelete()">🗑 Delete</button>
</div>
</div>
<!-- Dual Pane Container -->
<div class="sftp-dual-pane">
<!-- Local Panel (Left) -->
<div class="sftp-panel local-panel">
<div class="sftp-panel-header">
<div class="sftp-panel-title">💻 Local Site</div>
<div class="sftp-panel-path" id="sftpLocalPath">
📁 <input type="text" id="sftpLocalPathInput" value="Click here to select folder" readonly onclick="sftpChooseLocalFolder()">
</div>
</div>
<div class="sftp-file-list local-file-list" id="sftpLocalFiles"
ondrop="sftpHandleLocalFolderDrop(event)"
ondragover="sftpHandleLocalFolderDragOver(event)">
<div class="sftp-empty">
<div style="text-align: center;">
<div style="font-size: 32px; margin-bottom: 10px;">📂</div>
<div>Click to select a local folder</div>
<div style="font-size: 12px; color: #6b7280; margin-top: 8px;">or drop folder here to browse</div>
</div>
</div>
</div>
</div>
<!-- Transfer Arrows (Middle) -->
<div class="sftp-transfer-arrows">
<button class="sftp-arrow-btn" onclick="sftpUploadSelected()" title="Upload to remote">➡️</button>
<button class="sftp-arrow-btn" onclick="sftpDownloadSelected()" title="Download to local">⬅️</button>
</div>
<!-- Remote Panel (Right) -->
<div class="sftp-panel remote-panel">
<div class="sftp-panel-header">
<div class="sftp-panel-title">🖥️ Remote Site</div>
<div class="sftp-panel-path" id="sftpRemotePath">
<button class="sftp-nav-btn" onclick="sftpNavigateUp()">⬆️</button>
📁 <input type="text" id="sftpRemotePathInput" value="/" onkeydown="sftpHandlePathKey(event)">
</div>
</div>
<div class="sftp-file-list remote-file-list" id="sftpRemoteFiles" ondrop="sftpHandleDrop(event)" ondragover="sftpHandleDragOver(event)">
<div class="sftp-empty">Select a connection to browse files</div>
</div>
</div>
</div>
<!-- Transfer Queue (Bottom) -->
<div class="sftp-queue-section">
<div class="sftp-queue-header">
<span>📋 Transfer Queue</span>
<button class="btn-small" onclick="sftpClearCompleted()">Clear Completed</button>
</div>
<div class="sftp-queue-list" id="sftpQueueList">
<div class="sftp-queue-empty">No transfers in queue</div>
</div>
</div>
</div>
</div>
<!-- New Connection Modal -->
<div class="modal-overlay" id="newConnectionModal">
<div class="modal">
<h3>New Connection</h3>
<div class="form-group">
<label>Name</label>
<input id="modalName" placeholder="My Server">
</div>
<div class="form-group">
<label>Host</label>
<input id="modalHost" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label>Port</label>
<input id="modalPort" value="22">
</div>
<div class="form-group">
<label>Username</label>
<input id="modalUser" placeholder="root">
</div>
<div class="form-group">
<label>Authentication</label>
<select id="modalAuthType" onchange="toggleModalAuth()">
<option value="password">Password</option>
<option value="key">SSH Key</option>
</select>
</div>
<div class="form-group" id="modalPasswordGroup">
<label>Password</label>
<input type="password" id="modalPassword">
</div>
<div class="form-group hidden" id="modalKeyGroup">
<label>SSH Private Key</label>
<textarea id="modalKey" rows="4"></textarea>
</div>
<div class="form-row">
<div class="form-group" style="flex: 1;">
<label>Lock after (minutes of inactivity)</label>
<select id="modalLockTimeout">
<option value="5">5 minutes</option>
<option value="15" selected>15 minutes</option>
<option value="30">30 minutes</option>
<option value="60">60 minutes</option>
</select>
</div>
<div class="form-group" style="flex: 1;">
<label>Disconnect after (minutes of inactivity)</label>
<select id="modalDisconnectTimeout">
<option value="30">30 minutes</option>
<option value="60" selected>60 minutes</option>
<option value="0">Never</option>
</select>
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" onclick="hideModal()">Cancel</button>
<button onclick="createConnection()">Create</button>
</div>
</div>
</div>
<!-- Edit Connection Modal -->
<div class="modal-overlay" id="editConnectionModal">
<div class="modal">
<h3>Edit Connection</h3>
<div class="form-group">
<label>Name</label>
<input id="editModalName" placeholder="My Server">
</div>
<div class="form-group">
<label>Host</label>
<input id="editModalHost" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label>Port</label>
<input id="editModalPort" value="22">
</div>
<div class="form-group">
<label>Username</label>
<input id="editModalUser" placeholder="root">
</div>
<div class="form-group">
<label>Authentication</label>
<select id="editModalAuthType" onchange="toggleEditModalAuth()">
<option value="password">Password</option>
<option value="key">SSH Key</option>
</select>
</div>
<div class="form-group" id="editModalPasswordGroup">
<label>Password</label>
<input type="password" id="editModalPassword">
</div>
<div class="form-group hidden" id="editModalKeyGroup">
<label>SSH Private Key</label>
<textarea id="editModalKey" rows="4"></textarea>
</div>
<div class="form-row">
<div class="form-group" style="flex: 1;">
<label>Lock after (minutes of inactivity)</label>
<select id="editModalLockTimeout">
<option value="5">5 minutes</option>
<option value="15" selected>15 minutes</option>
<option value="30">30 minutes</option>
<option value="60">60 minutes</option>
</select>
</div>
<div class="form-group" style="flex: 1;">
<label>Disconnect after (minutes of inactivity)</label>
<select id="editModalDisconnectTimeout">
<option value="30">30 minutes</option>
<option value="60" selected>60 minutes</option>
<option value="0">Never</option>
</select>
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" onclick="hideEditConnectionModal()">Cancel</button>
<button onclick="saveEditedConnection()">Save</button>
</div>
</div>
</div>
<!-- Lock Screen Modal -->
<div class="modal-overlay" id="lockScreenModal" style="z-index: 10000; display: none;">
<div class="modal" style="text-align: center; max-width: 400px;">
<div class="lock-icon" style="font-size: 48px; margin-bottom: 20px;">🔒</div>
<h3>Session Locked</h3>
<p class="text-muted">Your session has been locked due to inactivity.</p>
<div class="form-group" style="margin-top: 20px;">
<label>Enter your StrixWebSSH password to unlock</label>
<input type="password" id="unlockPassword" placeholder="StrixWebSSH password" onkeydown="if(event.key==='Enter')unlockSession()">
</div>
<div id="unlockError" style="color: #ef4444; margin-top: 10px; display: none;">Incorrect password</div>
<div class="modal-actions" style="margin-top: 20px;">
<button onclick="logout()" class="btn-secondary">Logout</button>
<button onclick="unlockSession()">Unlock</button>
</div>
</div>
</div>
<!-- Help Modal -->
<!-- Recordings Modal -->
<div class="modal-overlay" id="recordingsModal">
<div class="modal" style="max-width: 900px; max-height: 80vh; overflow-y: auto;">
<div class="modal-header">
<h3>🎥 Session Recordings</h3>
<button class="modal-close" onclick="hideRecordings()">&times;</button>
</div>
<div class="modal-body">
<div id="recordingsList" class="recordings-list">
<p class="text-muted">Loading recordings...</p>
</div>
</div>
</div>
</div>
<!-- Recording Player Modal -->
<div class="modal-overlay" id="playerModal">
<div class="modal" style="max-width: 95%; max-height: 95%; overflow-y: auto;">
<div class="modal-header">
<h3>▶️ Recording Player</h3>
<button class="modal-close" onclick="hidePlayer()">&times;</button>
</div>
<div class="modal-body">
<div id="recordingInfo" class="recording-info"></div>
<div id="asciinemaPlayer" style="margin-top: 15px;"></div>
</div>
</div>
</div>
<!-- Lock Screen Modal -->
<div class="modal-overlay" id="lockScreenModal" style="z-index: 10000; display: none;">
<div class="modal" style="text-align: center; max-width: 400px;">
<div class="lock-icon" style="font-size: 48px; margin-bottom: 20px;">🔒</div>
<h3>Session Locked</h3>
<p class="text-muted">Your session has been locked due to inactivity.</p>
<div class="form-group" style="margin-top: 20px;">
<label>Enter your StrixWebSSH password to unlock</label>
<input type="password" id="unlockPassword" placeholder="StrixWebSSH password" onkeydown="if(event.key==='Enter')unlockSession()">
</div>
<div id="unlockError" style="color: #ef4444; margin-top: 10px; display: none;">Incorrect password</div>
<div class="modal-actions" style="margin-top: 20px;">
<button onclick="logout()" class="btn-secondary">Logout</button>
<button onclick="unlockSession()">Unlock</button>
</div>
</div>
</div>
<div class="modal-overlay" id="helpModal">
<div class="modal" style="max-width: 90%; max-height: 90%; overflow-y: auto;">
<div class="modal-header">
<h3>📚 StrixWebSSH Help</h3>
<button class="modal-close" onclick="hideHelp()">&times;</button>
</div>
<div class="modal-body">
<iframe src="help.html" style="width: 100%; height: 70vh; border: none; border-radius: 8px;"></iframe>
</div>
</div>
</div>
<script src="static/webssh.js"></script>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""
Entry point for WebSSH Backend.
This script starts the WebSSH server with all its components:
- HTTP API server (login, connections, SFTP REST endpoints)
- WebSocket SSH server (terminal access)
- WebSocket SFTP server (file operations)
- Periodic cleanup task for session recordings
Usage:
python run.py
Environment variables:
WEBSSH_USERS - Comma-separated user:pass pairs (default: admin:admin)
JWT_SECRET - Secret key for JWT tokens (auto-generated if not set)
MAX_UPLOAD_SIZE - Maximum file upload size (default: 100M)
RECORDING_ENABLED - Enable session recording (default: true)
RECORDING_RETENTION_DAYS - Days to keep recordings (default: 30)
STORAGE_DIR - Directory for data storage (default: /data)
"""
import sys
import os
# Add current directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from webssh_backend.main import main
from webssh_backend.config import logger
if __name__ == "__main__":
try:
import asyncio
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Server stopped by user")
except Exception as e:
logger.error(f"Fatal error: {e}")
sys.exit(1)
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
Simple HTTP server for static files + proxy to backend.
This serves the static files (index.html, help.html) and proxies API/WebSocket
requests to the backend servers.
Usage:
# Terminal 1: Start backend
python3 run.py
# Terminal 2: Start static server
python3 serve_static.py
# Then open http://localhost:8080/
"""
import http.server
import socketserver
import urllib.request
import urllib.error
import sys
import os
import json
import threading
import socket
PORT = int(os.environ.get("FRONTEND_PORT", "8080"))
BACKEND_API = "http://localhost:8082"
WS_HOST = "localhost"
WS_PORT = 8081
SFTP_WS_PORT = 8083
def load_backend_ports():
"""Read the backend-selected ports from the state file, if present."""
candidates = []
env_file = os.environ.get("STRIXWEBSSH_PORTS_FILE") or os.environ.get("WEBSSH_PORTS_FILE")
if env_file:
candidates.append(env_file)
candidates.extend([
os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", ".ports.json"),
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", ".ports.json"),
"/data/.ports.json",
])
for state_file in candidates:
if not state_file or not os.path.exists(state_file):
continue
try:
with open(state_file, "r") as handle:
data = json.load(handle)
return data
except Exception:
continue
return None
PORTS = load_backend_ports() or {}
if PORTS.get("http_port"):
BACKEND_API = f"http://localhost:{PORTS['http_port']}"
if PORTS.get("ws_port"):
WS_PORT = PORTS["ws_port"]
if PORTS.get("sftp_ws_port"):
SFTP_WS_PORT = PORTS["sftp_ws_port"]
def find_available_port(start_port: int) -> int:
"""Return the first available TCP port starting from start_port."""
for port in range(start_port, start_port + 100):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind(("", port))
return port
except OSError:
continue
raise OSError(f"No available port found in range {start_port}-{start_port + 99}")
class ProxyHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
# Serve files from current directory
super().__init__(*args, directory=os.path.dirname(os.path.abspath(__file__)), **kwargs)
def do_GET(self):
if self.path.startswith('/api/'):
self._proxy_request('GET')
return
if self.path.startswith('/ws'):
self._proxy_websocket()
return
super().do_GET()
def do_POST(self):
if self.path.startswith('/api/'):
self._proxy_request('POST')
return
self.send_error(404)
def do_OPTIONS(self):
if self.path.startswith('/api/'):
self._proxy_request('OPTIONS')
return
self.send_response(200)
self.end_headers()
def _proxy_websocket(self):
try:
host = WS_HOST
port = WS_PORT
self.send_response(101)
self.send_header('Upgrade', 'websocket')
self.send_header('Connection', 'Upgrade')
self.send_header('Sec-WebSocket-Accept', 'dummy')
self.end_headers()
import websocket
ws_client = websocket.create_connection(f"ws://{host}:{port}{self.path}", timeout=5)
while True:
msg = ws_client.recv()
if msg is None:
break
self.wfile.write(msg)
except Exception as exc:
print(f'WebSocket proxy error: {exc}')
def _proxy_request(self, method):
try:
url = BACKEND_API + self.path
# Read request body if any
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length) if content_length > 0 else None
# Create request
req = urllib.request.Request(url, method=method, data=body)
# Copy headers
for header, value in self.headers.items():
if header.lower() not in ('host', 'content-length'):
req.add_header(header, value)
# Send request to backend
with urllib.request.urlopen(req) as response:
body = response.read()
self.send_response(response.status)
for header, value in response.headers.items():
if header.lower() not in ('transfer-encoding', 'content-length'):
self.send_header(header, value)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
except urllib.error.HTTPError as e:
body = e.read()
self.send_response(e.code)
for header, value in e.headers.items():
if header.lower() not in ('transfer-encoding', 'content-length'):
self.send_header(header, value)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
except Exception as e:
body = f'Proxy error: {e}'.encode()
self.send_response(500)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
print(f"[{self.log_date_time_string()}] {format % args}")
if __name__ == "__main__":
try:
actual_port = find_available_port(PORT)
except OSError as exc:
print(f"Failed to find an available port: {exc}")
sys.exit(1)
if actual_port != PORT:
print(f"Port {PORT} is busy; using port {actual_port} instead")
print(f"Starting static file server on http://localhost:{actual_port}/")
print(f"API requests will be proxied to {BACKEND_API}")
print(f"\nOpen http://localhost:{actual_port}/ in your browser")
print("Note: Make sure the backend is running (python3 start_backend.py)")
print("\nPress Ctrl+C to stop")
with socketserver.TCPServer(("", actual_port), ProxyHandler) as httpd:
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nServer stopped")
sys.exit(0)
File diff suppressed because it is too large. Load diff
+2510
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
"""
StrixWebSSH Backend Package - Modular SSH and SFTP server.
This package provides a modular SSH and SFTP server with the following components:
- config: Configuration and constants (HOST, PORT, JWT settings, etc.)
- models: SSHConfig dataclass for SSH connection configuration
- database: CredentialsManager for SQLite operations
- recording: SessionRecorder for asciinema format session recording
- auth: JWT authentication functions
- http_api: HTTP handlers (LoginHandler)
- ssh_handler: WebSocket to SSH bridge (handle_client)
- sftp_handler: WebSocket SFTP operations (handle_sftp_client)
- utils: Helper functions (parse_size, is_rate_limited, get_client_ip)
- main: Entry point (main function)
Usage:
from webssh_backend.main import main
asyncio.run(main())
"""
__version__ = "0.3.1"
# Note: Specific imports are intentionally NOT done at package level
# to avoid loading all dependencies when only specific modules are needed.
# Import directly from the relevant module, e.g.:
# from webssh_backend.config import logger
# from webssh_backend.models import SSHConfig
# from webssh_backend.main import main
+69
View File
@@ -0,0 +1,69 @@
"""
Auth module - JWT authentication and user management.
"""
from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import parse_qs, urlparse
import jwt
from websockets.server import WebSocketServerProtocol
from .config import JWT_ALGORITHM, JWT_EXPIRE_HOURS, JWT_SECRET, USERS, logger
__all__ = ['verify_token', 'verify_token_ws', 'verify_token_http', 'generate_token', 'JWT_ALGORITHM', 'JWT_EXPIRE_HOURS', 'JWT_SECRET', 'USERS']
from .utils import get_client_ip
async def verify_token_ws(ws: WebSocketServerProtocol) -> Optional[str]:
"""Verify JWT token from WebSocket query parameters. Returns username or None."""
try:
# Get token from query string
path = ws.request.path if hasattr(ws.request, 'path') else str(ws.request)
parsed = urlparse(path)
params = parse_qs(parsed.query)
token = params.get('token', [''])[0]
if not token:
logger.warning(f"No token provided from {get_client_ip(ws)}")
return None
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload['sub']
except jwt.ExpiredSignatureError:
logger.warning(f"Expired token from {get_client_ip(ws)}")
return None
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid token from {get_client_ip(ws)}: {e}")
return None
except Exception as e:
logger.error(f"Token verification error: {e}")
return None
def generate_token(username: str) -> str:
"""Generate a new JWT token for the user."""
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
token = jwt.encode(
{'sub': username, 'exp': expire, 'iat': datetime.now(timezone.utc)},
JWT_SECRET,
algorithm=JWT_ALGORITHM
)
# Handle both PyJWT v1 (bytes) and v2 (str)
if isinstance(token, bytes):
token = token.decode('utf-8')
return token
def verify_token_http(token: str) -> Optional[str]:
"""Verify JWT token from HTTP request. Returns username or None."""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload.get('sub')
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
# For backward compatibility, keep the original function name
verify_token = verify_token_ws
+206
View File
@@ -0,0 +1,206 @@
"""
Configuration module - all constants and environment variables.
"""
import hashlib
import json
import logging
import os
import secrets
import socket
from cryptography.fernet import Fernet
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Network Configuration
HOST = "0.0.0.0"
def _is_port_available(port):
"""Return True when the given TCP port can be bound."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind(("", port))
return True
except OSError:
return False
def _get_port_from_env(env_names, default_port):
for env_name in env_names:
value = os.environ.get(env_name)
if value:
try:
return int(value)
except ValueError:
logger.warning(f"Invalid port value for {env_name}: {value}")
return default_port
def _find_available_port(start_port, reserved_ports=None):
reserved = set(reserved_ports or [])
for port in range(start_port, start_port + 100):
if port in reserved:
continue
if _is_port_available(port):
return port
raise OSError(f"No available port found in range {start_port}-{start_port + 99}")
def _resolve_port(env_names, default_port, reserved_ports=None):
requested_port = _get_port_from_env(env_names, default_port)
if requested_port not in (reserved_ports or []) and _is_port_available(requested_port):
return requested_port
return _find_available_port(requested_port, reserved_ports)
# SSH Configuration
SSH_TIMEOUT = 30 # seconds
MAX_MESSAGE_SIZE = 10 * 1024 * 1024 # 10MB
# Rate Limiting
RATE_LIMIT_WINDOW = 60 # seconds
RATE_LIMIT_MAX = 10 # max connections per IP per window
# SFTP Configuration
SFTP_CHUNK_SIZE = 64 * 1024 # 64KB chunks for transfers
def parse_size(size_str):
"""Parse size string like '50M' or '100MB' to bytes."""
size_str = size_str.upper().strip()
logger.info(f"parse_size called with: '{size_str}'")
if size_str.endswith('M') or size_str.endswith('MB'):
# Remove 'M' or 'MB' and convert to bytes
result = int(float(size_str[:-1]) * 1024 * 1024)
logger.info(f"parse_size result: {result} bytes (from {size_str})")
return result
elif size_str.endswith('K') or size_str.endswith('KB'):
# Remove 'K' or 'KB' and convert to bytes
result = int(float(size_str[:-1]) * 1024)
logger.info(f"parse_size result: {result} bytes (from {size_str})")
return result
elif size_str.endswith('G') or size_str.endswith('GB'):
# Remove 'G' or 'GB' and convert to bytes
result = int(float(size_str[:-1]) * 1024 * 1024 * 1024)
logger.info(f"parse_size result: {result} bytes (from {size_str})")
return result
else:
# Assume bytes if no suffix
result = int(size_str)
logger.info(f"parse_size result: {result} bytes (from {size_str})")
return result
MAX_UPLOAD_SIZE = parse_size(os.environ.get("MAX_UPLOAD_SIZE", "100M")) # 100MB default
logger.info(f"MAX_UPLOAD_SIZE loaded: {MAX_UPLOAD_SIZE} bytes ({MAX_UPLOAD_SIZE / 1024 / 1024:.1f}MB)")
# Storage Configuration
STORAGE_DIR = os.environ.get("STORAGE_DIR", "/data")
STORAGE_FILE = os.path.join(STORAGE_DIR, "credentials.db")
PORT_STATE_FILE = os.path.join(STORAGE_DIR, ".ports.json")
# Session Recording Configuration
RECORDING_ENABLED = os.environ.get("RECORDING_ENABLED", "true").lower() in ("true", "1", "yes")
RECORDING_RETENTION_DAYS = int(os.environ.get("RECORDING_RETENTION_DAYS", "30"))
RECORDINGS_PATH = os.environ.get("RECORDINGS_PATH", os.path.join(STORAGE_DIR, "recordings"))
logger.info(f"Session recording: enabled={RECORDING_ENABLED}, retention={RECORDING_RETENTION_DAYS}days, path={RECORDINGS_PATH}")
# JWT Configuration
JWT_SECRET = os.environ.get("JWT_SECRET", secrets.token_hex(32))
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 8
# Encryption Configuration for Credentials Storage
ENCRYPTION_KEY_FILE = os.path.join(STORAGE_DIR, ".encryption_key")
ENCRYPTION_KEY = os.environ.get("ENCRYPTION_KEY")
if not ENCRYPTION_KEY:
# Try to load existing key from file
if os.path.exists(ENCRYPTION_KEY_FILE):
try:
with open(ENCRYPTION_KEY_FILE, 'r') as f:
ENCRYPTION_KEY = f.read().strip()
logger.info("Loaded encryption key from file")
except Exception as e:
logger.warning(f"Failed to load encryption key from file: {e}")
if not ENCRYPTION_KEY:
# Generate a new random key
ENCRYPTION_KEY = Fernet.generate_key().decode()
try:
with open(ENCRYPTION_KEY_FILE, 'w') as f:
f.write(ENCRYPTION_KEY)
logger.info("Generated and saved new encryption key")
except Exception as e:
logger.error(f"Failed to save encryption key: {e}")
# Ensure storage directory exists (with fallback to local dir if no permission)
def ensure_storage_dir():
"""Try to create storage directory, fallback to local if no permission."""
global STORAGE_DIR, STORAGE_FILE, ENCRYPTION_KEY_FILE, RECORDINGS_PATH, PORT_STATE_FILE
try:
os.makedirs(STORAGE_DIR, exist_ok=True)
PORT_STATE_FILE = os.path.join(STORAGE_DIR, ".ports.json")
return
except (PermissionError, OSError) as e:
logger.warning(f"Cannot create storage dir {STORAGE_DIR}: {e}")
# Fallback to local directory
fallback_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'data')
try:
os.makedirs(fallback_dir, exist_ok=True)
STORAGE_DIR = fallback_dir
STORAGE_FILE = os.path.join(STORAGE_DIR, "credentials.db")
ENCRYPTION_KEY_FILE = os.path.join(STORAGE_DIR, ".encryption_key")
RECORDINGS_PATH = os.path.join(STORAGE_DIR, "recordings")
PORT_STATE_FILE = os.path.join(STORAGE_DIR, ".ports.json")
logger.info(f"Using fallback storage directory: {STORAGE_DIR}")
except (PermissionError, OSError) as e:
logger.error(f"Cannot create fallback dir {fallback_dir}: {e}")
raise
ensure_storage_dir()
# Resolve backend ports, falling back to the next free port when defaults are busy.
WS_PORT = _resolve_port(("STRIXWEBSSH_WS_PORT", "WEBSSH_WS_PORT"), 8081)
HTTP_PORT = _resolve_port(("STRIXWEBSSH_HTTP_PORT", "WEBSSH_HTTP_PORT"), 8082, reserved_ports={WS_PORT})
SFTP_WS_PORT = _resolve_port(("STRIXWEBSSH_SFTP_PORT", "WEBSSH_SFTP_PORT"), 8083, reserved_ports={WS_PORT, HTTP_PORT})
try:
with open(PORT_STATE_FILE, "w") as handle:
json.dump({
"ws_port": WS_PORT,
"http_port": HTTP_PORT,
"sftp_ws_port": SFTP_WS_PORT,
}, handle)
except Exception as exc:
logger.warning(f"Could not save selected ports to {PORT_STATE_FILE}: {exc}")
logger.info(f"Resolved backend ports: ws={WS_PORT}, http={HTTP_PORT}, sftp={SFTP_WS_PORT}")
# User Configuration (username: password_hash)
# Set via environment variable: STRIXWEBSSH_USERS=user1:pass1,user2:pass2
USERS = {}
users_env = os.environ.get("STRIXWEBSSH_USERS", os.environ.get("WEBSSH_USERS", "admin:admin"))
for user_cred in users_env.split(","):
if ":" in user_cred:
u, p = user_cred.split(":", 1)
USERS[u.strip()] = hashlib.sha256(p.strip().encode()).hexdigest()
logger.info(f"Loaded {len(USERS)} users: {list(USERS.keys())}")
# Log storage directory status
logger.info(f"Using storage directory: {STORAGE_DIR}")
try:
import stat as stat_module
stat_info = os.stat(STORAGE_DIR)
logger.info(f"Directory permissions: uid={stat_info.st_uid}, gid={stat_info.st_gid}, mode={oct(stat_info.st_mode)}")
except Exception as e:
logger.warning(f"Could not stat storage directory: {e}")
+458
View File
@@ -0,0 +1,458 @@
"""
Database module - CredentialsManager for SQLite operations.
"""
import os
import sqlite3
from typing import Any, Dict, List, Optional
from cryptography.fernet import Fernet
from .config import ENCRYPTION_KEY, STORAGE_FILE, logger
class CredentialsManager:
"""Manages encrypted storage of SSH credentials per user."""
def __init__(self, db_path: str, encryption_key: str):
self.db_path = db_path
self.cipher = Fernet(encryption_key.encode() if isinstance(encryption_key, str) else encryption_key)
# Ensure directory exists before initializing DB
db_dir = os.path.dirname(db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
self._init_db()
def _init_db(self):
"""Initialize SQLite database."""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS connections (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL,
ssh_username TEXT NOT NULL,
auth_type TEXT NOT NULL,
password_encrypted BLOB,
key_encrypted BLOB,
ws_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
# Migration: Create recordings table if not exists
cursor.execute('''
CREATE TABLE IF NOT EXISTS recordings (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
connection_id TEXT NOT NULL,
connection_name TEXT,
session_id TEXT NOT NULL,
host TEXT NOT NULL,
ssh_username TEXT NOT NULL,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ended_at TIMESTAMP,
duration_seconds INTEGER,
file_size_bytes INTEGER,
file_path TEXT NOT NULL,
width INTEGER DEFAULT 80,
height INTEGER DEFAULT 24,
FOREIGN KEY (connection_id) REFERENCES connections(id)
)
''')
# Migration: Add connection_name column if it doesn't exist (for existing databases)
try:
cursor.execute('ALTER TABLE recordings ADD COLUMN connection_name TEXT')
logger.info("Added connection_name column to recordings table")
except sqlite3.OperationalError:
# Column already exists
pass
cursor.execute('CREATE INDEX IF NOT EXISTS idx_recordings_username ON recordings(username)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_recordings_connection ON recordings(connection_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_recordings_started_at ON recordings(started_at)')
# Migration: Add lock_timeout and disconnect_timeout columns if they don't exist
try:
cursor.execute('ALTER TABLE connections ADD COLUMN lock_timeout INTEGER DEFAULT 15')
logger.info("Added lock_timeout column to connections table")
except sqlite3.OperationalError:
pass
try:
cursor.execute('ALTER TABLE connections ADD COLUMN disconnect_timeout INTEGER DEFAULT 60')
logger.info("Added disconnect_timeout column to connections table")
except sqlite3.OperationalError:
pass
conn.commit()
conn.close()
logger.info(f"Credentials database initialized at {self.db_path}")
def _encrypt(self, data: str) -> bytes:
"""Encrypt string data."""
if not data:
return None
return self.cipher.encrypt(data.encode())
def _decrypt(self, data: bytes) -> str:
"""Decrypt bytes to string."""
if not data:
return None
try:
return self.cipher.decrypt(data).decode()
except Exception as e:
logger.warning(f"Failed to decrypt data: {e}")
return None
def save_connection(self, username: str, conn_data: Dict[str, Any]) -> bool:
"""Save or update a connection."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
conn_id = conn_data.get('id')
name = conn_data.get('name', 'Unnamed')
host = conn_data.get('host', '')
port = conn_data.get('port', 22)
ssh_username = conn_data.get('username', '')
auth_type = conn_data.get('authType', 'password')
ws_url = conn_data.get('wsUrl', 'ws://localhost:2222')
lock_timeout = conn_data.get('lockTimeout', 15)
disconnect_timeout = conn_data.get('disconnectTimeout', 60)
# Encrypt sensitive data
password = conn_data.get('password', '')
key = conn_data.get('key', '')
password_encrypted = self._encrypt(password) if password else None
key_encrypted = self._encrypt(key) if key else None
cursor.execute('''
INSERT OR REPLACE INTO connections
(id, username, name, host, port, ssh_username, auth_type,
password_encrypted, key_encrypted, ws_url, lock_timeout, disconnect_timeout, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
''', (conn_id, username, name, host, port, ssh_username, auth_type,
password_encrypted, key_encrypted, ws_url, lock_timeout, disconnect_timeout))
conn.commit()
conn.close()
logger.info(f"Saved connection {name} for user {username}")
return True
except Exception as e:
logger.error(f"Failed to save connection: {e}")
return False
def get_connections(self, username: str) -> list:
"""Get all connections for a user."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
logger.info(f"Querying connections for username: '{username}'") # DEBUG
# First, let's see all connections to debug
cursor.execute('SELECT DISTINCT username FROM connections')
all_users = cursor.fetchall()
logger.info(f"All users in DB: {all_users}") # DEBUG
cursor.execute('''
SELECT id, name, host, port, ssh_username, auth_type,
password_encrypted, key_encrypted, ws_url, lock_timeout, disconnect_timeout
FROM connections WHERE username = ?
''', (username,))
rows = cursor.fetchall()
logger.info(f"Found {len(rows)} rows for username '{username}'") # DEBUG
conn.close()
connections = []
for i, row in enumerate(rows):
try:
logger.info(f"Processing row {i}: id={row[0]}, name={row[1]}") # DEBUG
conn_dict = {
'id': row[0],
'name': row[1],
'host': row[2],
'port': row[3],
'username': row[4],
'authType': row[5],
'password': self._decrypt(row[6]) if row[6] else '',
'key': self._decrypt(row[7]) if row[7] else '',
'wsUrl': row[8] if row[8] else 'ws://localhost:2222',
'lockTimeout': row[9] if row[9] is not None else 15,
'disconnectTimeout': row[10] if row[10] is not None else 60,
}
connections.append(conn_dict)
logger.info(f"Successfully processed row {i}") # DEBUG
except Exception as row_error:
logger.error(f"Error processing row {i}: {row_error}")
import traceback
logger.error(traceback.format_exc())
return connections
except Exception as e:
logger.error(f"Failed to get connections: {e}")
import traceback
logger.error(traceback.format_exc())
return []
def delete_connection(self, username: str, conn_id: str) -> bool:
"""Delete a connection."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute(
"DELETE FROM connections WHERE id = ? AND username = ?",
(conn_id, username)
)
conn.commit()
conn.close()
logger.info(f"Deleted connection {conn_id} for user {username}")
return True
except Exception as e:
logger.error(f"Failed to delete connection: {e}")
return False
# Session Recording Methods
def save_recording(self, recording_data: Dict[str, Any]) -> bool:
"""Save recording metadata to database."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO recordings
(id, username, connection_id, connection_name, session_id, host, ssh_username,
started_at, ended_at, duration_seconds, file_size_bytes, file_path, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
recording_data['id'],
recording_data['username'],
recording_data['connection_id'],
recording_data.get('connection_name', recording_data['host']),
recording_data['session_id'],
recording_data['host'],
recording_data['ssh_username'],
recording_data.get('started_at'),
recording_data.get('ended_at'),
recording_data.get('duration_seconds'),
recording_data.get('file_size_bytes'),
recording_data['file_path'],
recording_data.get('width', 80),
recording_data.get('height', 24)
))
conn.commit()
conn.close()
logger.info(f"Saved recording metadata: {recording_data['id']}")
return True
except Exception as e:
logger.error(f"Failed to save recording: {e}")
return False
def update_recording_end(self, recording_id: str, ended_at: str, duration: int, file_size: int) -> bool:
"""Update recording with end time and file info."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
UPDATE recordings
SET ended_at = ?, duration_seconds = ?, file_size_bytes = ?
WHERE id = ?
''', (ended_at, duration, file_size, recording_id))
conn.commit()
conn.close()
logger.info(f"Updated recording end: {recording_id}")
return True
except Exception as e:
logger.error(f"Failed to update recording: {e}")
return False
def get_recordings(self, username: str, connection_id: str = None) -> list:
"""Get all recordings for a user, optionally filtered by connection."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
if connection_id:
cursor.execute('''
SELECT id, connection_id, connection_name, session_id, host, ssh_username,
started_at, ended_at, duration_seconds, file_size_bytes, file_path, width, height
FROM recordings WHERE username = ? AND connection_id = ?
ORDER BY started_at DESC
''', (username, connection_id))
else:
cursor.execute('''
SELECT id, connection_id, connection_name, session_id, host, ssh_username,
started_at, ended_at, duration_seconds, file_size_bytes, file_path, width, height
FROM recordings WHERE username = ?
ORDER BY started_at DESC
''', (username,))
rows = cursor.fetchall()
conn.close()
recordings = []
for row in rows:
recordings.append({
'id': row[0],
'connection_id': row[1],
'connection_name': row[2],
'session_id': row[3],
'host': row[4],
'ssh_username': row[5],
'started_at': row[6],
'ended_at': row[7],
'duration_seconds': row[8],
'file_size_bytes': row[9],
'file_path': row[10],
'width': row[11],
'height': row[12]
})
return recordings
except Exception as e:
logger.error(f"Failed to get recordings: {e}")
return []
def get_recording_by_id(self, recording_id: str, username: str) -> Optional[Dict]:
"""Get a specific recording by ID."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
SELECT id, connection_id, connection_name, session_id, host, ssh_username,
started_at, ended_at, duration_seconds, file_size_bytes, file_path, width, height
FROM recordings WHERE id = ? AND username = ?
''', (recording_id, username))
row = cursor.fetchone()
conn.close()
if row:
return {
'id': row[0],
'connection_id': row[1],
'connection_name': row[2],
'session_id': row[3],
'host': row[4],
'ssh_username': row[5],
'started_at': row[6],
'ended_at': row[7],
'duration_seconds': row[8],
'file_size_bytes': row[9],
'file_path': row[10],
'width': row[11],
'height': row[12]
}
return None
except Exception as e:
logger.error(f"Failed to get recording: {e}")
return None
def delete_recording(self, recording_id: str, username: str) -> bool:
"""Delete a recording from database."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute(
"DELETE FROM recordings WHERE id = ? AND username = ?",
(recording_id, username)
)
conn.commit()
conn.close()
logger.info(f"Deleted recording {recording_id} for user {username}")
return True
except Exception as e:
logger.error(f"Failed to delete recording: {e}")
return False
def get_connections_with_recordings(self, username: str) -> list:
"""Get unique connections that have recordings for a user."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
SELECT DISTINCT connection_id, connection_name, host, ssh_username,
COUNT(*) as recording_count,
SUM(file_size_bytes) as total_size,
MAX(started_at) as last_recording
FROM recordings
WHERE username = ? AND connection_id IS NOT NULL
GROUP BY connection_id, connection_name, host, ssh_username
ORDER BY last_recording DESC
''', (username,))
rows = cursor.fetchall()
conn.close()
return [{
'connection_id': row[0],
'connection_name': row[1],
'host': row[2],
'ssh_username': row[3],
'recording_count': row[4],
'total_size_bytes': row[5] or 0,
'last_recording': row[6]
} for row in rows]
except Exception as e:
logger.error(f"Failed to get connections with recordings: {e}")
return []
def delete_recordings_by_connection(self, username: str, connection_id: str) -> tuple:
"""Delete all recordings for a specific connection. Returns (success_count, failed_count)."""
try:
# First get all recordings to delete files
recordings = self.get_recordings(username, connection_id)
success_count = 0
failed_count = 0
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
for rec in recordings:
try:
# Delete file if exists
file_path = rec.get('file_path')
if file_path and os.path.exists(file_path):
os.remove(file_path)
# Delete from database
cursor.execute(
"DELETE FROM recordings WHERE id = ? AND username = ?",
(rec['id'], username)
)
success_count += 1
except Exception as e:
logger.error(f"Failed to delete recording {rec.get('id')}: {e}")
failed_count += 1
conn.commit()
conn.close()
logger.info(f"Deleted {success_count} recordings for connection {connection_id}, failed: {failed_count}")
return (success_count, failed_count)
except Exception as e:
logger.error(f"Failed to delete recordings by connection: {e}")
return (0, 0)
def get_old_recordings(self, before_date: str) -> list:
"""Get recordings older than a specific date for cleanup."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
SELECT id, username, file_path FROM recordings
WHERE started_at < ?
''', (before_date,))
rows = cursor.fetchall()
conn.close()
return [{'id': row[0], 'username': row[1], 'file_path': row[2]} for row in rows]
except Exception as e:
logger.error(f"Failed to get old recordings: {e}")
return []
# Initialize credentials manager (singleton)
try:
creds_manager = CredentialsManager(STORAGE_FILE, ENCRYPTION_KEY)
logger.info("Credentials manager initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize credentials manager: {e}")
import traceback
logger.error(traceback.format_exc())
creds_manager = None # Will fail later when used
+899
View File
@@ -0,0 +1,899 @@
"""
HTTP API module - LoginHandler with all HTTP endpoints.
"""
import hashlib
import io
import json
import os
import stat
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler
from typing import Optional
from urllib.parse import parse_qs, urlparse
import jwt
import paramiko
from .auth import JWT_ALGORITHM, JWT_EXPIRE_HOURS, JWT_SECRET, USERS, generate_token, verify_token_http
from .config import HTTP_PORT, MAX_UPLOAD_SIZE, SFTP_CHUNK_SIZE, WS_PORT, logger
from .database import creds_manager
APP_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATIC_DIR = os.path.join(APP_ROOT, 'static')
INDEX_PATH = os.path.join(APP_ROOT, 'index.html')
HELP_PATH = os.path.join(APP_ROOT, 'help.html')
class LoginHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
logger.info(f"HTTP {args[0]} {args[1]}")
def do_OPTIONS(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
self.end_headers()
def do_POST(self):
parsed = urlparse(self.path)
if parsed.path == '/api/login':
self.handle_login()
elif parsed.path == '/api/verify':
self.handle_verify()
elif parsed.path == '/api/connections':
self.handle_save_connection()
elif parsed.path == '/api/config':
self.handle_config()
elif parsed.path == '/api/sftp/list':
self.handle_sftp_list()
elif parsed.path == '/api/sftp/download':
self.handle_sftp_download()
elif parsed.path == '/api/sftp/upload':
self.handle_sftp_upload()
elif parsed.path == '/api/sftp/mkdir':
self.handle_sftp_mkdir()
elif parsed.path == '/api/sftp/rm':
self.handle_sftp_rm()
elif parsed.path == '/api/sftp/mv':
self.handle_sftp_mv()
elif parsed.path == '/api/recordings/delete':
self.handle_delete_recording()
elif parsed.path == '/api/recordings/delete-all':
self.handle_delete_all_recordings()
else:
self.send_error(404)
def do_GET(self):
parsed = urlparse(self.path)
# API endpoint for connections
if parsed.path == '/api/connections':
self.handle_get_connections()
return
# API endpoint for config
if parsed.path == '/api/config':
self.handle_config()
return
# API endpoint for recordings list
if parsed.path == '/api/recordings':
self.handle_get_recordings()
return
# API endpoint for connections with recordings
if parsed.path == '/api/recordings/connections':
self.handle_get_connections_with_recordings()
return
# API endpoint for download all recordings for a connection
if parsed.path == '/api/recordings/download-all':
self.handle_download_all_recordings()
return
# API endpoint for recording details or download
if parsed.path.startswith('/api/recordings/'):
parts = parsed.path.split('/')
if len(parts) >= 4:
recording_id = parts[3]
if len(parts) >= 5 and parts[4] == 'download':
self.handle_download_recording(recording_id)
else:
self.handle_get_recording(recording_id)
return
# Static file serving
file_path = parsed.path
# Default to index.html for root
if file_path == '/' or file_path == '/index.html':
full_path = INDEX_PATH
elif file_path == '/help.html':
full_path = HELP_PATH
elif file_path.startswith('/static/'):
relative_path = file_path[len('/static/'):] # Remove '/static/' prefix
full_path = os.path.join(STATIC_DIR, relative_path)
else:
self.send_error(404)
return
# Security check - prevent directory traversal
full_path = os.path.realpath(full_path)
app_root = os.path.realpath(APP_ROOT)
if os.path.commonpath([app_root, full_path]) != app_root:
self.send_error(403)
return
# Check if file exists
if not os.path.exists(full_path) or not os.path.isfile(full_path):
self.send_error(404)
return
# Determine content type
content_type = 'application/octet-stream'
if full_path.endswith('.html'):
content_type = 'text/html'
elif full_path.endswith('.js'):
content_type = 'application/javascript'
elif full_path.endswith('.css'):
content_type = 'text/css'
# Serve the file
try:
with open(full_path, 'rb') as f:
content = f.read()
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', len(content))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(content)
except Exception as e:
logger.error(f"Error serving file {full_path}: {e}")
self.send_error(500)
def _get_username_from_token(self) -> Optional[str]:
"""Extract username from JWT token in Authorization header."""
auth_header = self.headers.get('Authorization', '')
if auth_header.startswith('Bearer '):
token = auth_header[7:]
return verify_token_http(token)
return None
def handle_save_connection(self):
"""Save or update a connection."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
if creds_manager.save_connection(username, data):
self.send_json_response(200, {'success': True})
else:
self.send_json_response(500, {'error': 'Failed to save connection'})
except Exception as e:
logger.error(f"Save connection error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_get_connections(self):
"""Get all connections for the user."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
connections = creds_manager.get_connections(username)
logger.info(f"Retrieved {len(connections)} connections for user {username}")
self.send_json_response(200, {'connections': connections})
except Exception as e:
logger.error(f"Get connections error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_config(self):
"""Get configuration including max upload size."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
config = {
'max_upload_size': MAX_UPLOAD_SIZE,
'max_upload_size_mb': MAX_UPLOAD_SIZE / 1024 / 1024,
'stored_users': USERS, # Include hashed passwords for client-side verification
'current_user': username,
'ws_port': WS_PORT,
'http_port': HTTP_PORT,
'sftp_ws_port': SFTP_CHUNK_SIZE
}
self.send_json_response(200, {'config': config})
except Exception as e:
logger.error(f"Get config error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_delete_connection(self):
"""Delete a connection."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
conn_id = data.get('id')
if not conn_id:
self.send_json_response(400, {'error': 'Connection ID required'})
return
if creds_manager.delete_connection(username, conn_id):
self.send_json_response(200, {'success': True})
else:
self.send_json_response(500, {'error': 'Failed to delete connection'})
except Exception as e:
logger.error(f"Delete connection error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_login(self):
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
username = data.get('username', '').strip()
password = data.get('password', '')
if not username or not password:
self.send_json_response(400, {'error': 'Username and password required'})
return
# Check credentials
password_hash = hashlib.sha256(password.encode()).hexdigest()
if USERS.get(username) != password_hash:
logger.warning(f"Failed login attempt for user: {username}")
self.send_json_response(401, {'error': 'Invalid credentials'})
return
# Generate JWT token
token = generate_token(username)
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
logger.info(f"Successful login for user: {username}")
self.send_json_response(200, {'token': token, 'username': username, 'expires': expire.isoformat()})
except json.JSONDecodeError:
self.send_json_response(400, {'error': 'Invalid JSON'})
except Exception as e:
logger.error(f"Login error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_verify(self):
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
token = data.get('token', '')
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
self.send_json_response(200, {'valid': True, 'username': payload['sub']})
except jwt.ExpiredSignatureError:
self.send_json_response(401, {'valid': False, 'error': 'Token expired'})
except jwt.InvalidTokenError:
self.send_json_response(401, {'valid': False, 'error': 'Invalid token'})
except Exception as e:
logger.error(f"Verify error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def send_json_response(self, status, data):
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(data).encode())
def _get_ssh_connection(self, conn_data: dict) -> paramiko.SSHClient:
"""Establish SSH connection from connection data."""
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
connect_kwargs = {
"hostname": conn_data['host'],
"port": conn_data.get('port', 22),
"username": conn_data['username'],
"timeout": 30, # SSH_TIMEOUT
"look_for_keys": False,
"allow_agent": False
}
if conn_data.get('key'):
pkey = None
key_data = conn_data['key']
for key_type in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
try:
pkey = key_type.from_private_key(io.StringIO(key_data))
break
except:
continue
if pkey:
connect_kwargs["pkey"] = pkey
else:
raise ValueError("Invalid SSH key")
elif conn_data.get('password'):
connect_kwargs["password"] = conn_data['password']
else:
raise ValueError("No authentication method provided")
ssh.connect(**connect_kwargs)
return ssh
def handle_sftp_list(self):
"""List files in remote directory via SFTP."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
conn_id = data.get('conn_id')
remote_path = data.get('path', '.')
# Get connection details from stored connections
connections = creds_manager.get_connections(username)
conn_data = next((c for c in connections if c['id'] == conn_id), None)
if not conn_data:
self.send_json_response(404, {'error': 'Connection not found'})
return
# Establish SSH and SFTP connection
ssh = self._get_ssh_connection(conn_data)
sftp = ssh.open_sftp()
try:
# List directory
entries = sftp.listdir_attr(remote_path)
files = []
for entry in entries:
files.append({
'name': entry.filename,
'size': entry.st_size,
'is_dir': stat.S_ISDIR(entry.st_mode),
'mtime': entry.st_mtime,
'mode': oct(entry.st_mode)[-3:]
})
# Sort: directories first, then files
files.sort(key=lambda x: (not x['is_dir'], x['name'].lower()))
self.send_json_response(200, {'files': files, 'path': remote_path})
finally:
sftp.close()
ssh.close()
except Exception as e:
logger.error(f"SFTP list error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_sftp_download(self):
"""Stream file download via SFTP without caching."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
conn_id = data.get('conn_id')
remote_path = data.get('path')
if not remote_path:
self.send_json_response(400, {'error': 'Path required'})
return
# Get connection details
connections = creds_manager.get_connections(username)
conn_data = next((c for c in connections if c['id'] == conn_id), None)
if not conn_data:
self.send_json_response(404, {'error': 'Connection not found'})
return
# Establish SSH and SFTP
ssh = self._get_ssh_connection(conn_data)
sftp = ssh.open_sftp()
try:
# Get file info
stat_info = sftp.stat(remote_path)
if stat_info.st_mode & 0o170000 == 0o040000: # Check if it's a directory
self.send_json_response(400, {'error': 'Path is a directory'})
return
file_size = stat_info.st_size
filename = os.path.basename(remote_path)
# Send headers for streaming download
self.send_response(200)
self.send_header('Content-Type', 'application/octet-stream')
self.send_header('Content-Disposition', f'attachment; filename="{filename}"')
self.send_header('Content-Length', str(file_size))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
# Stream file in chunks
with sftp.file(remote_path, 'rb') as f:
while True:
chunk = f.read(SFTP_CHUNK_SIZE)
if not chunk:
break
self.wfile.write(chunk)
finally:
sftp.close()
ssh.close()
except Exception as e:
logger.error(f"SFTP download error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_sftp_upload(self):
"""Handle file upload via HTTP POST."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
# Parse multipart form data
content_type = self.headers.get('Content-Type', '')
logger.info(f"Upload content-type: {content_type}")
if not content_type.startswith('multipart/form-data'):
self.send_json_response(400, {'error': 'multipart/form-data required'})
return
# Extract boundary and parse form data
boundary = content_type.split('boundary=')[1].encode()
content_length = int(self.headers.get('Content-Length', 0))
logger.info(f"Upload content-length: {content_length}")
post_data = self.rfile.read(content_length)
logger.info(f"Read {len(post_data)} bytes of post data")
# Parse multipart data
parts = post_data.split(b'--' + boundary)
logger.info(f"Found {len(parts)} parts in multipart data")
conn_id = None
remote_path = None
file_data = None
filename = None
for i, part in enumerate(parts):
if b'Content-Disposition: form-data' in part:
lines = part.split(b'\r\n')
logger.info(f"Part {i} has {len(lines)} lines")
if len(lines) > 1:
disposition_line = lines[1].decode()
logger.info(f"Disposition: {disposition_line}")
if 'name="conn_id"' in disposition_line:
if len(lines) > 3:
conn_id = lines[3].decode().strip()
logger.info(f"Found conn_id: {conn_id}")
elif 'name="remote_path"' in disposition_line:
if len(lines) > 3:
remote_path = lines[3].decode().strip()
logger.info(f"Found remote_path: {remote_path}")
elif 'name="file"' in disposition_line:
# Extract filename from disposition
if 'filename=' in disposition_line:
filename = disposition_line.split('filename="')[1].split('"')[0]
logger.info(f"Found filename: {filename}")
# Find file data (skip headers)
file_start = 2 # Skip first two lines
while file_start < len(lines) and lines[file_start]:
file_start += 1
file_start += 1 # Skip empty line
if file_start < len(lines):
# Join remaining lines as file data
file_data = b'\r\n'.join(lines[file_start:-1]) # Exclude ending boundary
logger.info(f"Found file data: {len(file_data)} bytes")
logger.info(f"Final values: conn_id={conn_id}, remote_path={remote_path}, filename={filename}, file_data_len={len(file_data) if file_data else 0}")
if not conn_id or not remote_path or not file_data:
self.send_json_response(400, {'error': f'Missing required fields: conn_id={conn_id}, remote_path={remote_path}, file_data={len(file_data) if file_data else None}'})
return
# Check file size limit
if len(file_data) > MAX_UPLOAD_SIZE:
self.send_json_response(413, {'error': f'File too large. Max size: {MAX_UPLOAD_SIZE / 1024 / 1024:.1f}MB'})
return
# Get connection details
connections = creds_manager.get_connections(username)
conn_data = next((c for c in connections if c['id'] == conn_id), None)
if not conn_data:
self.send_json_response(404, {'error': 'Connection not found'})
return
# Establish SSH and SFTP
ssh = self._get_ssh_connection(conn_data)
sftp = ssh.open_sftp()
try:
# Upload file
with sftp.file(remote_path, 'wb') as f:
f.write(file_data)
self.send_json_response(200, {'success': True, 'filename': filename})
finally:
sftp.close()
ssh.close()
except Exception as e:
logger.error(f"SFTP upload error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_sftp_mkdir(self):
"""Create directory via SFTP."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
conn_id = data.get('conn_id')
remote_path = data.get('path')
if not remote_path:
self.send_json_response(400, {'error': 'Path required'})
return
connections = creds_manager.get_connections(username)
conn_data = next((c for c in connections if c['id'] == conn_id), None)
if not conn_data:
self.send_json_response(404, {'error': 'Connection not found'})
return
ssh = self._get_ssh_connection(conn_data)
sftp = ssh.open_sftp()
try:
sftp.mkdir(remote_path)
self.send_json_response(200, {'success': True})
finally:
sftp.close()
ssh.close()
except Exception as e:
logger.error(f"SFTP mkdir error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_sftp_rm(self):
"""Remove file or directory via SFTP."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
conn_id = data.get('conn_id')
remote_path = data.get('path')
is_dir = data.get('is_dir', False)
if not remote_path:
self.send_json_response(400, {'error': 'Path required'})
return
connections = creds_manager.get_connections(username)
conn_data = next((c for c in connections if c['id'] == conn_id), None)
if not conn_data:
self.send_json_response(404, {'error': 'Connection not found'})
return
ssh = self._get_ssh_connection(conn_data)
sftp = ssh.open_sftp()
try:
if is_dir:
sftp.rmdir(remote_path)
else:
sftp.remove(remote_path)
self.send_json_response(200, {'success': True})
finally:
sftp.close()
ssh.close()
except Exception as e:
logger.error(f"SFTP rm error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_sftp_mv(self):
"""Rename/move file via SFTP."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
conn_id = data.get('conn_id')
src_path = data.get('src')
dst_path = data.get('dst')
if not src_path or not dst_path:
self.send_json_response(400, {'error': 'Source and destination paths required'})
return
connections = creds_manager.get_connections(username)
conn_data = next((c for c in connections if c['id'] == conn_id), None)
if not conn_data:
self.send_json_response(404, {'error': 'Connection not found'})
return
ssh = self._get_ssh_connection(conn_data)
sftp = ssh.open_sftp()
try:
sftp.rename(src_path, dst_path)
self.send_json_response(200, {'success': True})
finally:
sftp.close()
ssh.close()
except Exception as e:
logger.error(f"SFTP mv error: {e}")
self.send_json_response(500, {'error': str(e)})
# Session Recording Handlers
def handle_get_recordings(self):
"""Get list of recordings for the authenticated user."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
# Get optional connection_id filter from query params
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
connection_id = params.get('connection_id', [None])[0]
recordings = creds_manager.get_recordings(username, connection_id)
self.send_json_response(200, {'recordings': recordings})
except Exception as e:
logger.error(f"Get recordings error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_get_recording(self, recording_id: str):
"""Get details of a specific recording."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
recording = creds_manager.get_recording_by_id(recording_id, username)
if not recording:
self.send_json_response(404, {'error': 'Recording not found'})
return
self.send_json_response(200, {'recording': recording})
except Exception as e:
logger.error(f"Get recording error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_download_recording(self, recording_id: str):
"""Download a recording file."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
recording = creds_manager.get_recording_by_id(recording_id, username)
if not recording:
self.send_json_response(404, {'error': 'Recording not found'})
return
file_path = recording.get('file_path')
if not file_path or not os.path.exists(file_path):
self.send_json_response(404, {'error': 'Recording file not found'})
return
# Send file
self.send_response(200)
self.send_header('Content-Type', 'application/gzip')
self.send_header('Content-Disposition', f'attachment; filename="{os.path.basename(file_path)}"')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
with open(file_path, 'rb') as f:
self.wfile.write(f.read())
except Exception as e:
logger.error(f"Download recording error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_delete_recording(self):
"""Delete a recording."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
recording_id = data.get('recording_id')
if not recording_id:
self.send_json_response(400, {'error': 'recording_id required'})
return
# Get recording to find file path
recording = creds_manager.get_recording_by_id(recording_id, username)
if not recording:
self.send_json_response(404, {'error': 'Recording not found'})
return
# Delete file
file_path = recording.get('file_path')
if file_path and os.path.exists(file_path):
os.remove(file_path)
logger.info(f"Deleted recording file: {file_path}")
# Delete from database
if creds_manager.delete_recording(recording_id, username):
self.send_json_response(200, {'success': True})
else:
self.send_json_response(500, {'error': 'Failed to delete recording'})
except Exception as e:
logger.error(f"Delete recording error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_get_connections_with_recordings(self):
"""Get list of connections that have recordings."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
connections = creds_manager.get_connections_with_recordings(username)
self.send_json_response(200, {'connections': connections})
except Exception as e:
logger.error(f"Get connections with recordings error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_delete_all_recordings(self):
"""Delete all recordings for a specific connection."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
connection_id = data.get('connection_id')
if not connection_id:
self.send_json_response(400, {'error': 'connection_id required'})
return
success_count, failed_count = creds_manager.delete_recordings_by_connection(username, connection_id)
if success_count > 0 or failed_count == 0:
self.send_json_response(200, {
'success': True,
'deleted_count': success_count,
'failed_count': failed_count
})
else:
self.send_json_response(500, {'error': f'Failed to delete {failed_count} recordings'})
except Exception as e:
logger.error(f"Delete all recordings error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_download_all_recordings(self):
"""Download all recordings for a connection as a zip file."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
# Get connection_id from query params
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
connection_id = params.get('connection_id', [None])[0]
if not connection_id:
self.send_json_response(400, {'error': 'connection_id required'})
return
# Get all recordings for this connection
recordings = creds_manager.get_recordings(username, connection_id)
if not recordings:
self.send_json_response(404, {'error': 'No recordings found for this connection'})
return
# Get connection info for filename
connections = creds_manager.get_connections_with_recordings(username)
conn_info = next((c for c in connections if c['connection_id'] == connection_id), None)
conn_name = conn_info['connection_name'] if conn_info else connection_id
# Create zip file in memory
import zipfile
import io
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for rec in recordings:
file_path = rec.get('file_path')
if file_path and os.path.exists(file_path):
# Add file to zip with a descriptive name
filename = f"{rec['connection_name']}_{rec['started_at'].replace(':', '-')}.cast.gz"
zip_file.write(file_path, filename)
zip_buffer.seek(0)
zip_data = zip_buffer.read()
# Send zip file
self.send_response(200)
self.send_header('Content-Type', 'application/zip')
self.send_header('Content-Disposition', f'attachment; filename="{conn_name}_recordings.zip"')
self.send_header('Content-Length', len(zip_data))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(zip_data)
logger.info(f"Downloaded {len(recordings)} recordings for connection {connection_id}")
except Exception as e:
logger.error(f"Download all recordings error: {e}")
self.send_json_response(500, {'error': str(e)})
+52
View File
@@ -0,0 +1,52 @@
"""
Main entry point - starts all servers.
"""
import asyncio
import threading
from http.server import HTTPServer
import websockets
from .config import HTTP_PORT, HOST, MAX_MESSAGE_SIZE, MAX_UPLOAD_SIZE, RECORDING_ENABLED, RECORDING_RETENTION_DAYS, SFTP_WS_PORT, WS_PORT, logger
from .http_api import LoginHandler
from .recording import cleanup_old_recordings
from .sftp_handler import handle_sftp_client
from .ssh_handler import handle_client
async def periodic_cleanup():
"""Run cleanup of old recordings periodically."""
while True:
try:
await asyncio.sleep(86400) # Run once per day (24 hours)
cleanup_old_recordings()
except Exception as e:
logger.error(f"Periodic cleanup error: {e}")
async def main():
logger.info(f"StrixWebSSH server starting on ws://{HOST}:{WS_PORT}")
logger.info(f"SFTP server starting on ws://{HOST}:{SFTP_WS_PORT}")
logger.info(f"Login API available on http://{HOST}:{HTTP_PORT}")
# Start HTTP server for login API in a separate thread
http_server = HTTPServer((HOST, HTTP_PORT), LoginHandler)
http_thread = threading.Thread(target=http_server.serve_forever, daemon=True)
http_thread.start()
logger.info(f"HTTP login server started on port {HTTP_PORT}")
# Start cleanup task if recording is enabled
if RECORDING_ENABLED and RECORDING_RETENTION_DAYS > 0:
cleanup_task = asyncio.create_task(periodic_cleanup())
logger.info(f"Started periodic cleanup task (retention: {RECORDING_RETENTION_DAYS} days)")
# Start both WebSocket servers
async with websockets.serve(handle_client, HOST, WS_PORT, max_size=MAX_MESSAGE_SIZE, ping_interval=20, ping_timeout=10), \
websockets.serve(handle_sftp_client, HOST, SFTP_WS_PORT, max_size=MAX_UPLOAD_SIZE + 1024*1024, ping_interval=20, ping_timeout=10):
logger.info(f"WebSocket SSH server started on port {WS_PORT}")
logger.info(f"WebSocket SFTP server started on port {SFTP_WS_PORT}")
await asyncio.Future()
if __name__ == "__main__":
asyncio.run(main())
+31
View File
@@ -0,0 +1,31 @@
"""
Models module - SSHConfig dataclass with validation.
"""
import re
from dataclasses import dataclass
from typing import Optional
@dataclass
class SSHConfig:
host: str
port: int
username: str
password: Optional[str] = None
key_data: Optional[str] = None
def validate(self) -> None:
if not self.host or not isinstance(self.host, str):
raise ValueError("Invalid host")
if not re.match(r'^[a-zA-Z0-9_.-]+$', self.host):
raise ValueError("Host contains invalid characters")
if not 1 <= self.port <= 65535:
raise ValueError("Port must be between 1 and 65535")
if not self.username or len(self.username) > 32:
raise ValueError("Invalid username")
if not self.password and not self.key_data:
raise ValueError("Password or SSH key required")
if self.password and len(self.password) > 1024:
raise ValueError("Password too long")
if self.key_data and len(self.key_data) > 50000:
raise ValueError("SSH key too large")
+163
View File
@@ -0,0 +1,163 @@
"""
Recording module - SessionRecorder and cleanup functions.
"""
import gzip
import json
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Dict
from .config import RECORDING_ENABLED, RECORDING_RETENTION_DAYS, RECORDINGS_PATH, logger
from .database import creds_manager
class SessionRecorder:
"""Records SSH session output in asciinema v2 format."""
def __init__(self, username: str, connection_id: str, session_id: str,
host: str, ssh_username: str, connection_name: str = None,
width: int = 80, height: int = 24):
self.username = username
self.connection_id = connection_id
self.session_id = session_id
self.host = host
self.ssh_username = ssh_username
self.connection_name = connection_name or host # Use host as fallback
self.width = width
self.height = height
self.recording_id = str(uuid.uuid4())
self.start_time = time.time()
self.last_event_time = self.start_time
self.events = []
self.file_path = None
self.file_handle = None
self.gz_handle = None
# Ensure recordings directory exists
self.recordings_dir = os.path.join(RECORDINGS_PATH, username, connection_id)
os.makedirs(self.recordings_dir, exist_ok=True)
# Create file path: {session_id}_{timestamp}.cast.gz
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{session_id}_{timestamp}.cast.gz"
self.file_path = os.path.join(self.recordings_dir, filename)
# Open compressed file and write header
self._open_file()
logger.info(f"Started recording session {session_id} for user {username}")
def _open_file(self):
"""Open the recording file and write asciinema v2 header."""
try:
self.gz_handle = gzip.open(self.file_path, 'wt', encoding='utf-8')
# Write asciinema v2 header
header = {
"version": 2,
"width": self.width,
"height": self.height,
"timestamp": int(self.start_time),
"env": {"TERM": "xterm-256color", "SHELL": "/bin/bash"}
}
self.gz_handle.write(json.dumps(header) + "\n")
except Exception as e:
logger.error(f"Failed to open recording file: {e}")
raise
def record_output(self, data: str):
"""Record stdout output with timestamp."""
if not self.gz_handle:
return
try:
# Calculate time delta from start
current_time = time.time()
time_delta = current_time - self.start_time
# asciinema format: [time, "o", "output"]
# "o" = output (stdout)
event = [time_delta, "o", data]
self.events.append(event)
# Write immediately to file
self.gz_handle.write(json.dumps(event) + "\n")
self.gz_handle.flush()
self.last_event_time = current_time
except Exception as e:
logger.error(f"Failed to record output: {e}")
def finalize(self) -> Dict[str, Any]:
"""Finalize recording and return metadata."""
if self.gz_handle:
try:
self.gz_handle.close()
except Exception as e:
logger.error(f"Failed to close recording file: {e}")
end_time = time.time()
duration = int(end_time - self.start_time)
# Get file size
file_size = 0
if os.path.exists(self.file_path):
file_size = os.path.getsize(self.file_path)
metadata = {
'id': self.recording_id,
'username': self.username,
'connection_id': self.connection_id,
'connection_name': self.connection_name,
'session_id': self.session_id,
'host': self.host,
'ssh_username': self.ssh_username,
'started_at': datetime.fromtimestamp(self.start_time, tz=timezone.utc).isoformat(),
'ended_at': datetime.fromtimestamp(end_time, tz=timezone.utc).isoformat(),
'duration_seconds': duration,
'file_size_bytes': file_size,
'file_path': self.file_path,
'width': self.width,
'height': self.height
}
logger.info(f"Finalized recording {self.recording_id}: {duration}s, {file_size} bytes")
return metadata
def cleanup(self):
"""Clean up recording file if needed."""
if self.gz_handle:
try:
self.gz_handle.close()
except:
pass
def cleanup_old_recordings():
"""Remove recordings older than RECORDING_RETENTION_DAYS."""
if not RECORDING_ENABLED or RECORDING_RETENTION_DAYS <= 0:
return
try:
cutoff_date = (datetime.now() - timedelta(days=RECORDING_RETENTION_DAYS)).isoformat()
old_recordings = creds_manager.get_old_recordings(cutoff_date)
deleted_count = 0
for rec in old_recordings:
try:
# Delete file
if os.path.exists(rec['file_path']):
os.remove(rec['file_path'])
logger.info(f"Deleted old recording file: {rec['file_path']}")
# Delete from database
creds_manager.delete_recording(rec['id'], rec['username'])
deleted_count += 1
except Exception as e:
logger.error(f"Failed to cleanup recording {rec['id']}: {e}")
if deleted_count > 0:
logger.info(f"Cleaned up {deleted_count} old recordings")
except Exception as e:
logger.error(f"Failed to cleanup old recordings: {e}")
+214
View File
@@ -0,0 +1,214 @@
"""
SFTP Handler module - WebSocket SFTP operations.
"""
import asyncio
import base64
import io
import json
import stat
from typing import Optional
import paramiko
import websockets
from websockets.server import WebSocketServerProtocol
from .auth import verify_token
from .config import HOST, MAX_UPLOAD_SIZE, SFTP_WS_PORT, SSH_TIMEOUT, logger
from .database import creds_manager
from .utils import get_client_ip
async def handle_sftp_client(ws: WebSocketServerProtocol):
"""Handle SFTP file operations via WebSocket with progress tracking."""
client_ip = get_client_ip(ws)
# Verify JWT token first
username = await verify_token(ws)
if not username:
logger.warning(f"SFTP authentication failed for {client_ip}")
await ws.close(1008, "Authentication required")
return
logger.info(f"SFTP client {username} connected from {client_ip}")
ssh = None
sftp = None
current_file = None
upload_state = {'bytes_received': 0, 'total_size': 0, 'remote_path': ''}
try:
async for message in ws:
try:
data = json.loads(message)
msg_type = data.get('type')
if msg_type == 'init':
# Initialize SFTP connection
conn_id = data.get('conn_id')
connections = creds_manager.get_connections(username)
conn_data = next((c for c in connections if c['id'] == conn_id), None)
if not conn_data:
await ws.send(json.dumps({'type': 'error', 'message': 'Connection not found'}))
continue
# Connect SSH
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
connect_kwargs = {
"hostname": conn_data['host'],
"port": conn_data.get('port', 22),
"username": conn_data['username'],
"timeout": SSH_TIMEOUT,
"look_for_keys": False,
"allow_agent": False
}
if conn_data.get('key'):
for key_type in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
try:
connect_kwargs["pkey"] = key_type.from_private_key(io.StringIO(conn_data['key']))
break
except:
continue
elif conn_data.get('password'):
connect_kwargs["password"] = conn_data['password']
await asyncio.get_event_loop().run_in_executor(None, lambda: ssh.connect(**connect_kwargs))
sftp = ssh.open_sftp()
await ws.send(json.dumps({'type': 'ready'}))
logger.info(f"SFTP ready for {conn_data['host']}")
elif msg_type == 'upload_start':
# Start file upload
if not sftp:
await ws.send(json.dumps({'type': 'error', 'message': 'SFTP not connected'}))
continue
upload_state['remote_path'] = data.get('remote_path')
upload_state['total_size'] = data.get('total_size', 0)
upload_state['bytes_received'] = 0
# Check file size limit
if upload_state['total_size'] > MAX_UPLOAD_SIZE:
await ws.send(json.dumps({
'type': 'error',
'message': f'File too large. Max size: {MAX_UPLOAD_SIZE / 1024 / 1024:.1f}MB'
}))
continue
# Open file for writing
current_file = sftp.file(upload_state['remote_path'], 'wb')
await ws.send(json.dumps({'type': 'upload_ready'}))
elif msg_type == 'upload_chunk':
# Receive file chunk
if not current_file:
await ws.send(json.dumps({'type': 'error', 'message': 'No upload in progress'}))
continue
chunk_data = base64.b64decode(data.get('data', ''))
current_file.write(chunk_data)
upload_state['bytes_received'] += len(chunk_data)
# Send progress
progress = int((upload_state['bytes_received'] / upload_state['total_size']) * 100) if upload_state['total_size'] > 0 else 0
await ws.send(json.dumps({
'type': 'progress',
'bytes_received': upload_state['bytes_received'],
'total_size': upload_state['total_size'],
'progress': progress
}))
elif msg_type == 'upload_complete':
# Finish upload
if current_file:
current_file.close()
current_file = None
await ws.send(json.dumps({
'type': 'upload_done',
'success': True,
'bytes_received': upload_state['bytes_received']
}))
logger.info(f"Upload complete: {upload_state['remote_path']} ({upload_state['bytes_received']} bytes)")
elif msg_type == 'list':
# List directory
if not sftp:
await ws.send(json.dumps({'type': 'error', 'message': 'SFTP not connected'}))
continue
path = data.get('path', '.')
entries = sftp.listdir_attr(path)
files = []
for entry in entries:
files.append({
'name': entry.filename,
'size': entry.st_size,
'is_dir': stat.S_ISDIR(entry.st_mode),
'mtime': entry.st_mtime,
'mode': oct(entry.st_mode)[-3:]
})
files.sort(key=lambda x: (not x['is_dir'], x['name'].lower()))
await ws.send(json.dumps({'type': 'file_list', 'files': files, 'path': path}))
elif msg_type == 'mkdir':
if not sftp:
await ws.send(json.dumps({'type': 'error', 'message': 'SFTP not connected'}))
continue
sftp.mkdir(data.get('path'))
await ws.send(json.dumps({'type': 'success', 'action': 'mkdir'}))
elif msg_type == 'rm':
if not sftp:
await ws.send(json.dumps({'type': 'error', 'message': 'SFTP not connected'}))
continue
path = data.get('path')
is_dir = data.get('is_dir', False)
if is_dir:
sftp.rmdir(path)
else:
sftp.remove(path)
await ws.send(json.dumps({'type': 'success', 'action': 'rm'}))
elif msg_type == 'mv':
if not sftp:
await ws.send(json.dumps({'type': 'error', 'message': 'SFTP not connected'}))
continue
sftp.rename(data.get('src'), data.get('dst'))
await ws.send(json.dumps({'type': 'success', 'action': 'mv'}))
except Exception as e:
logger.error(f"SFTP error for {client_ip}: {e}")
try:
await ws.send(json.dumps({'type': 'error', 'message': str(e)}))
except:
pass
except websockets.exceptions.ConnectionClosed:
pass
finally:
if current_file:
try:
current_file.close()
except:
pass
if sftp:
try:
sftp.close()
except:
pass
if ssh:
try:
ssh.close()
except:
pass
logger.info(f"SFTP client disconnected: {client_ip}")
+270
View File
@@ -0,0 +1,270 @@
"""
SSH Handler module - WebSocket to SSH bridge.
"""
import asyncio
import io
import json
import socket
import traceback
import uuid
from typing import Optional
import paramiko
import websockets
from websockets.server import WebSocketServerProtocol
from .auth import verify_token
from .config import HOST, MAX_MESSAGE_SIZE, RECORDING_ENABLED, SSH_TIMEOUT, WS_PORT, logger
from .database import creds_manager
from .models import SSHConfig
from .recording import SessionRecorder
from .utils import get_client_ip, is_rate_limited
async def handle_client(ws: WebSocketServerProtocol):
"""Handle WebSocket client and bridge to SSH server."""
client_ip = get_client_ip(ws)
# Verify JWT token first
username = await verify_token(ws)
if not username:
logger.warning(f"Authentication failed for {client_ip}")
await ws.close(1008, "Authentication required")
return
logger.info(f"Authenticated user {username} connected from {client_ip}")
if is_rate_limited(client_ip):
logger.warning(f"Rate limit exceeded for {client_ip}")
await ws.close(1013, "Rate limit exceeded")
return
ssh: Optional[paramiko.SSHClient] = None
chan: Optional[paramiko.Channel] = None
recorder = None # Initialize recorder variable before try block
try:
# Receive and validate initialization
try:
init = await asyncio.wait_for(ws.recv(), timeout=10)
except asyncio.TimeoutError:
raise ValueError("Initialization timeout")
if len(init) > MAX_MESSAGE_SIZE:
raise ValueError("Message too large")
data = json.loads(init)
config = SSHConfig(
host=data.get("host", "").strip(),
port=int(data.get("port", 22)),
username=data.get("username", "").strip(),
password=data.get("password"),
key_data=data.get("key")
)
config.validate()
logger.info(f"Connecting to {config.username}@{config.host}:{config.port} from {client_ip}")
logger.info(f"Auth type: {'key' if config.key_data else 'password'}")
# Establish SSH connection
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # For testing only!
connect_kwargs = {
"hostname": config.host,
"port": config.port,
"username": config.username,
"timeout": SSH_TIMEOUT,
"look_for_keys": False,
"allow_agent": False
}
if config.key_data:
logger.info(f"Key data length: {len(config.key_data)} chars")
pkey = None
key_errors = []
# Try RSA first
try:
pkey = paramiko.RSAKey.from_private_key(io.StringIO(config.key_data))
logger.info("Using RSA key authentication")
except paramiko.SSHException as e:
key_errors.append(f"RSA: {e}")
# Try Ed25519
try:
pkey = paramiko.Ed25519Key.from_private_key(io.StringIO(config.key_data))
logger.info("Using Ed25519 key authentication")
except paramiko.SSHException as e2:
key_errors.append(f"Ed25519: {e2}")
# Try ECDSA
try:
pkey = paramiko.ECDSAKey.from_private_key(io.StringIO(config.key_data))
logger.info("Using ECDSA key authentication")
except paramiko.SSHException as e3:
key_errors.append(f"ECDSA: {e3}")
# Try DSS (deprecated in newer paramiko)
pass
if pkey is None:
logger.error(f"All key types failed: {key_errors}")
raise ValueError(f"Invalid SSH key format. Tried: {', '.join([k.split(':')[0] for k in key_errors])}")
connect_kwargs["pkey"] = pkey
else:
connect_kwargs["password"] = config.password
logger.info("Using password authentication")
# Connect with timeout
logger.info(f"Attempting SSH connection to {config.host}:{config.port}...")
await asyncio.get_event_loop().run_in_executor(
None, lambda: ssh.connect(**connect_kwargs)
)
logger.info("SSH transport established")
logger.info("Invoking shell...")
chan = ssh.invoke_shell(term='xterm')
chan.setblocking(False)
chan.settimeout(SSH_TIMEOUT)
logger.info("Shell channel created successfully")
logger.info(f"SSH session established for {client_ip}")
await ws.send(json.dumps({"type": "connected"}))
# Initialize session recording if enabled
session_id = data.get("session_id", str(uuid.uuid4()))
connection_id = data.get("connection_id", "unknown")
connection_name = data.get("connection_name", config.host) # Get connection name from frontend
if RECORDING_ENABLED:
try:
# Get terminal size from data or use defaults
width = data.get("cols", 80)
height = data.get("rows", 24)
recorder = SessionRecorder(
username=username,
connection_id=connection_id,
session_id=session_id,
host=config.host,
ssh_username=config.username,
connection_name=connection_name,
width=width,
height=height
)
logger.info(f"Session recording started: {session_id} ({connection_name})")
except Exception as e:
logger.error(f"Failed to start session recording: {e}")
recorder = None
# Flag to signal when WebSocket is closed
ws_closed = asyncio.Event()
async def ws_to_ssh():
try:
async for msg in ws:
if len(msg) > MAX_MESSAGE_SIZE:
logger.warning(f"Large message from {client_ip}")
continue
# Handle resize message
try:
if msg.startswith('{'):
j = json.loads(msg)
if j.get("type") == "resize":
cols = max(1, min(j.get("cols", 80), 500))
rows = max(1, min(j.get("rows", 24), 500))
chan.resize_pty(width=cols, height=rows)
continue
except json.JSONDecodeError:
pass
# Send to SSH
if chan.active:
chan.send(msg)
except websockets.exceptions.ConnectionClosed:
pass
finally:
# Signal that WebSocket is closed
ws_closed.set()
logger.info(f"ws_to_ssh finished for {client_ip}")
async def ssh_to_ws():
try:
while chan.active and not ws_closed.is_set():
await asyncio.sleep(0.01)
if chan.recv_ready():
data = chan.recv(4096)
if not data:
break
decoded_data = data.decode(errors="ignore")
# Try to send to WebSocket, stop if closed
try:
await ws.send(decoded_data)
except websockets.exceptions.ConnectionClosed:
logger.info(f"WebSocket closed, stopping ssh_to_ws for {client_ip}")
break
# Record output if recorder is active
if recorder:
recorder.record_output(decoded_data)
if chan.exit_status_ready():
break
except Exception as e:
logger.error(f"SSH read error for {client_ip}: {e}")
finally:
logger.info(f"ssh_to_ws finished for {client_ip}")
await asyncio.gather(ws_to_ssh(), ssh_to_ws())
logger.info(f"Session ended for {client_ip}")
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON from {client_ip}: {e}")
await ws.send(json.dumps({"type": "error", "message": "Invalid JSON"}))
except ValueError as e:
logger.warning(f"Validation error from {client_ip}: {e}")
await ws.send(json.dumps({"type": "error", "message": str(e)}))
except paramiko.AuthenticationException as e:
logger.warning(f"Authentication failed for {client_ip}: {e}")
try:
await ws.send(json.dumps({"type": "error", "message": f"Authentication failed: {e}"}))
except:
pass
except (paramiko.SSHException, socket.error) as e:
logger.error(f"SSH error for {client_ip}: {type(e).__name__}: {e}")
try:
await ws.send(json.dumps({"type": "error", "message": f"SSH connection failed: {e}"}))
except:
pass
except Exception as e:
logger.error(f"Unexpected error for {client_ip}: {type(e).__name__}: {e}")
logger.error(traceback.format_exc())
try:
await ws.send(json.dumps({"type": "error", "message": f"Internal error: {type(e).__name__}"}))
except:
pass
finally:
logger.info(f"Entering finally block for {client_ip}, recorder={'exists' if recorder else 'none'}")
# Finalize session recording in finally block to ensure it always runs
if recorder:
try:
logger.info(f"Finalizing recording for session {recorder.session_id}")
metadata = recorder.finalize()
logger.info(f"Got metadata, saving to DB: {metadata['id']}")
result = creds_manager.save_recording(metadata)
logger.info(f"DB save result: {result}")
except Exception as e:
logger.error(f"Failed to finalize session recording: {e}")
recorder.cleanup()
if chan:
try:
chan.close()
except:
pass
if ssh:
try:
ssh.close()
except:
pass
logger.info(f"Connection closed for {client_ip}")
+34
View File
@@ -0,0 +1,34 @@
"""
Utils module - helper functions.
"""
import time
from typing import Dict, List
from .config import RATE_LIMIT_MAX, RATE_LIMIT_WINDOW, logger
# Rate limiting storage
connection_attempts: Dict[str, List[float]] = {}
def is_rate_limited(client_ip: str) -> bool:
"""Check if client IP has exceeded rate limit."""
now = time.time()
attempts = connection_attempts.get(client_ip, [])
attempts = [t for t in attempts if now - t < RATE_LIMIT_WINDOW]
connection_attempts[client_ip] = attempts
if len(attempts) >= RATE_LIMIT_MAX:
return True
attempts.append(now)
return False
def get_client_ip(ws) -> str:
"""Extract client IP from WebSocket connection."""
# New websockets API uses request.headers instead of request_headers
try:
forwarded = ws.request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
except AttributeError:
pass
return ws.remote_address[0] if ws.remote_address else "unknown"