Initial commit
Build and Push Multi-Arch Docker Image / build (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build (push) Has been cancelled
This commit is contained in:
commit
8ce01cb00a
34 files changed
+8346
No files matched your search
Whitespace-only changes.
@@ -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
@@ -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
@@ -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()">×</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()">×</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()">×</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
@@ -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)
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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)})
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user