diff --git a/fastapi_ssh_server.py b/fastapi_ssh_server.py new file mode 100644 index 000000000..d5d5a1f53 --- /dev/null +++ b/fastapi_ssh_server.py @@ -0,0 +1,153 @@ +import asyncio +import asyncssh +import tempfile +import os +from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query +from fastapi.middleware.cors import CORSMiddleware +import paramiko # For key generation and manipulation +import io +import pwd +from jose import jwt, JWTError +import logging + +app = FastAPI() +JWT_SECRET = "YOUR_SECRET_KEY" +JWT_ALGORITHM = "HS256" + +# Allow CORS for local dev/testing +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +SSH_USER = "your_website_user" # Replace with a real user for testing +AUTHORIZED_KEYS_PATH = f"/home/{SSH_USER}/.ssh/authorized_keys" + +# Helper to generate a keypair +def generate_ssh_keypair(): + key = paramiko.RSAKey.generate(2048) + private_io = io.StringIO() + key.write_private_key(private_io) + private_key = private_io.getvalue() + public_key = f"{key.get_name()} {key.get_base64()}" + return private_key, public_key + +# Add public key to authorized_keys with a unique comment +def add_key_to_authorized_keys(public_key, comment): + entry = f'from="127.0.0.1" {public_key} {comment}\n' + with open(AUTHORIZED_KEYS_PATH, "a") as f: + f.write(entry) + +# Remove public key from authorized_keys by comment +def remove_key_from_authorized_keys(comment): + with open(AUTHORIZED_KEYS_PATH, "r") as f: + lines = f.readlines() + with open(AUTHORIZED_KEYS_PATH, "w") as f: + for line in lines: + if comment not in line: + f.write(line) + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket, token: str = Query(None), ssh_user: str = Query(None)): + # Re-enable JWT validation + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + user = payload.get("ssh_user") + if not user: + await websocket.close() + return + except JWTError: + await websocket.close() + return + home_dir = pwd.getpwnam(user).pw_dir + ssh_dir = os.path.join(home_dir, ".ssh") + authorized_keys_path = os.path.join(ssh_dir, "authorized_keys") + + os.makedirs(ssh_dir, exist_ok=True) + if not os.path.exists(authorized_keys_path): + with open(authorized_keys_path, "w"): pass + os.chown(ssh_dir, pwd.getpwnam(user).pw_uid, pwd.getpwnam(user).pw_gid) + os.chmod(ssh_dir, 0o700) + os.chown(authorized_keys_path, pwd.getpwnam(user).pw_uid, pwd.getpwnam(user).pw_gid) + os.chmod(authorized_keys_path, 0o600) + + private_key, public_key = generate_ssh_keypair() + comment = f"webterm-{os.urandom(8).hex()}" + entry = f'from="127.0.0.1" {public_key} {comment}\n' + with open(authorized_keys_path, "a") as f: + f.write(entry) + + with tempfile.NamedTemporaryFile(delete=False) as keyfile: + keyfile.write(private_key.encode()) + keyfile_path = keyfile.name + + await websocket.accept() + conn = None + process = None + try: + conn = await asyncssh.connect( + "localhost", + username=user, + client_keys=[keyfile_path], + known_hosts=None + ) + process = await conn.create_process(term_type="xterm") + + async def ws_to_ssh(): + try: + while True: + data = await websocket.receive_bytes() + # Decode bytes to str before writing to SSH stdin + process.stdin.write(data.decode('utf-8', errors='replace')) + except WebSocketDisconnect: + process.stdin.close() + + async def ssh_to_ws(): + try: + while not process.stdout.at_eof(): + data = await process.stdout.read(1024) + if data: + # Defensive type check and logging + logging.debug(f"[ssh_to_ws] Sending to WS: type={type(data)}, sample={data[:40] if isinstance(data, bytes) else data}") + if isinstance(data, bytes): + await websocket.send_bytes(data) + elif isinstance(data, str): + await websocket.send_text(data) + else: + await websocket.send_text(str(data)) + except Exception as ex: + logging.exception(f"[ssh_to_ws] Exception: {ex}") + pass + + await asyncio.gather(ws_to_ssh(), ssh_to_ws()) + except Exception as e: + try: + # Always send error as text (string) + msg = f"Connection error: {e}" + logging.exception(f"[websocket_endpoint] Exception: {e}") + if isinstance(msg, bytes): + msg = msg.decode('utf-8', errors='replace') + await websocket.send_text(str(msg)) + except Exception as ex: + logging.exception(f"[websocket_endpoint] Error sending error message: {ex}") + pass + try: + await websocket.close() + except Exception: + pass + finally: + # Remove key from authorized_keys and delete temp private key + with open(authorized_keys_path, "r") as f: + lines = f.readlines() + with open(authorized_keys_path, "w") as f: + for line in lines: + if comment not in line: + f.write(line) + os.remove(keyfile_path) + if process: + process.close() + if conn: + conn.close() diff --git a/fastapi_ssh_server.service b/fastapi_ssh_server.service new file mode 100644 index 000000000..916b777ca --- /dev/null +++ b/fastapi_ssh_server.service @@ -0,0 +1,14 @@ +[Unit] +Description=FastAPI SSH Web Terminal Server +After=network.target + +[Service] +Type=simple +WorkingDirectory=/usr/local/CyberCP +ExecStart=/usr/local/CyberCP/bin/python3 -m uvicorn fastapi_ssh_server:app --host 0.0.0.0 --port 8888 --ssl-keyfile=/usr/local/lscp/conf/key.pem --ssl-certfile=/usr/local/lscp/conf/cert.pem +Restart=on-failure +User=root +Group=root + +[Install] +WantedBy=multi-user.target diff --git a/requirments-old.txt b/requirments-old.txt index 427741460..d4860e191 100644 --- a/requirments-old.txt +++ b/requirments-old.txt @@ -30,3 +30,9 @@ tldextract==3.0.2 tornado==6.1 validators==0.18.1 websocket-client==0.57.0 + +fastapi +uvicorn +asyncssh +python-jose +websockets \ No newline at end of file diff --git a/websiteFunctions/static/websiteFunctions/websiteFunctions.js b/websiteFunctions/static/websiteFunctions/websiteFunctions.js index ecf1752a9..807ee0cd1 100755 --- a/websiteFunctions/static/websiteFunctions/websiteFunctions.js +++ b/websiteFunctions/static/websiteFunctions/websiteFunctions.js @@ -10093,6 +10093,100 @@ function website_child_domain_checkbox_function() { app.controller('websitePages', function ($scope, $http, $timeout, $window) { + $scope.openWebTerminal = function() { + console.log('[DEBUG] openWebTerminal called'); + $('#web-terminal-modal').modal('show'); + console.log('[DEBUG] Modal should now be visible'); + + if ($scope.term) { + console.log('[DEBUG] Disposing previous terminal instance'); + $scope.term.dispose(); + } + var term = new Terminal({ + cursorBlink: true, + fontFamily: 'monospace', + fontSize: 14, + theme: { background: '#000' } + }); + $scope.term = term; + term.open(document.getElementById('xterm-container')); + term.focus(); + console.log('[DEBUG] Terminal initialized and opened'); + + // Fetch JWT from backend with CSRF token + var domain = $("#domainNamePage").text(); + var csrftoken = getCookie('csrftoken'); + console.log('[DEBUG] Fetching JWT for domain:', domain); + $http.post('/websites/getTerminalJWT', { domain: domain }, { + headers: { 'X-CSRFToken': csrftoken } + }) + .then(function(response) { + console.log('[DEBUG] JWT fetch response:', response); + if (response.data.status === 1 && response.data.token) { + var token = response.data.token; + var ssh_user = response.data.ssh_user; + var wsProto = location.protocol === 'https:' ? 'wss' : 'ws'; + var wsUrl = wsProto + '://' + window.location.hostname + ':8888/ws?token=' + encodeURIComponent(token) + '&ssh_user=' + encodeURIComponent(ssh_user); + console.log('[DEBUG] Connecting to WebSocket:', wsUrl); + var socket = new WebSocket(wsUrl); + socket.binaryType = 'arraybuffer'; + $scope.terminalSocket = socket; + + socket.onopen = function() { + console.log('[DEBUG] WebSocket connection opened'); + term.write('\x1b[32mConnected.\x1b[0m\r\n'); + }; + socket.onclose = function(event) { + console.log('[DEBUG] WebSocket connection closed', event); + term.write('\r\n\x1b[31mConnection closed.\x1b[0m\r\n'); + // Optionally, log modal state + console.log('[DEBUG] Modal state on close:', $('#web-terminal-modal').is(':visible')); + }; + socket.onerror = function(e) { + console.log('[DEBUG] WebSocket error', e); + term.write('\r\n\x1b[31mWebSocket error.\x1b[0m\r\n'); + }; + socket.onmessage = function(event) { + if (event.data instanceof ArrayBuffer) { + var text = new Uint8Array(event.data); + term.write(new TextDecoder().decode(text)); + } else if (typeof event.data === 'string') { + term.write(event.data); + } + }; + term.onData(function(data) { + if (socket.readyState === WebSocket.OPEN) { + var encoder = new TextEncoder(); + socket.send(encoder.encode(data)); + } + }); + term.onResize(function(size) { + if (socket.readyState === WebSocket.OPEN) { + var msg = JSON.stringify({resize: {cols: size.cols, rows: size.rows}}); + socket.send(msg); + } + }); + $('#web-terminal-modal').on('hidden.bs.modal', function() { + console.log('[DEBUG] Modal hidden event triggered'); + if ($scope.term) { + $scope.term.dispose(); + $scope.term = null; + } + if ($scope.terminalSocket) { + $scope.terminalSocket.close(); + $scope.terminalSocket = null; + } + }); + } else { + console.log('[DEBUG] Failed to get terminal token', response); + term.write('\x1b[31mFailed to get terminal token.\x1b[0m\r\n'); + } + }, function(error) { + console.log('[DEBUG] Failed to contact backend', error); + term.write('\x1b[31mFailed to contact backend.\x1b[0m\r\n'); + }); + }; + $scope.logFileLoading = true; $scope.logsFeteched = true; $scope.couldNotFetchLogs = true; @@ -14666,6 +14760,85 @@ app.controller('installMauticCTRL', function ($scope, $http, $timeout) { app.controller('sshAccess', function ($scope, $http, $timeout) { + $scope.openWebTerminal = function() { + $('#web-terminal-modal').modal('show'); + + if ($scope.term) { + $scope.term.dispose(); + } + var term = new Terminal({ + cursorBlink: true, + fontFamily: 'monospace', + fontSize: 14, + theme: { background: '#000' } + }); + $scope.term = term; + term.open(document.getElementById('xterm-container')); + term.focus(); + + // Fetch JWT from backend with CSRF token + var domain = $("#domainName").text(); + var csrftoken = getCookie('csrftoken'); + $http.post('/websites/getTerminalJWT', { domain: domain }, { + headers: { 'X-CSRFToken': csrftoken } + }) + .then(function(response) { + if (response.data.status === 1 && response.data.token) { + var token = response.data.token; + var ssh_user = $("#externalApp").text(); + var wsProto = location.protocol === 'https:' ? 'wss' : 'ws'; + var wsUrl = wsProto + '://' + window.location.hostname + ':8888/ws?token=' + encodeURIComponent(token) + '&ssh_user=' + encodeURIComponent(ssh_user); + var socket = new WebSocket(wsUrl); + socket.binaryType = 'arraybuffer'; + $scope.terminalSocket = socket; + + socket.onopen = function() { + term.write('\x1b[32mConnected.\x1b[0m\r\n'); + }; + socket.onclose = function() { + term.write('\r\n\x1b[31mConnection closed.\x1b[0m\r\n'); + }; + socket.onerror = function(e) { + term.write('\r\n\x1b[31mWebSocket error.\x1b[0m\r\n'); + }; + socket.onmessage = function(event) { + if (event.data instanceof ArrayBuffer) { + var text = new Uint8Array(event.data); + term.write(new TextDecoder().decode(text)); + } else if (typeof event.data === 'string') { + term.write(event.data); + } + }; + term.onData(function(data) { + if (socket.readyState === WebSocket.OPEN) { + var encoder = new TextEncoder(); + socket.send(encoder.encode(data)); + } + }); + term.onResize(function(size) { + if (socket.readyState === WebSocket.OPEN) { + var msg = JSON.stringify({resize: {cols: size.cols, rows: size.rows}}); + socket.send(msg); + } + }); + $('#web-terminal-modal').on('hidden.bs.modal', function() { + if ($scope.term) { + $scope.term.dispose(); + $scope.term = null; + } + if ($scope.terminalSocket) { + $scope.terminalSocket.close(); + $scope.terminalSocket = null; + } + }); + } else { + term.write('\x1b[31mFailed to get terminal token.\x1b[0m\r\n'); + } + }, function() { + term.write('\x1b[31mFailed to contact backend.\x1b[0m\r\n'); + }); + }; + $scope.wpInstallLoading = true; $scope.setupSSHAccess = function () { @@ -17837,4 +18010,3 @@ app.controller('launchChild', function ($scope, $http) { } }); - diff --git a/websiteFunctions/templates/websiteFunctions/sshAccess.html b/websiteFunctions/templates/websiteFunctions/sshAccess.html index 75305fea1..2b989e51f 100755 --- a/websiteFunctions/templates/websiteFunctions/sshAccess.html +++ b/websiteFunctions/templates/websiteFunctions/sshAccess.html @@ -7,57 +7,245 @@ {% get_current_language as LANGUAGE_CODE %} -
{% trans "Set up SSH access and enable/disable CageFS for " %} {{ domainName }}. {% trans " CageFS require CloudLinux OS." %}
-{% trans "Set up SSH access and enable/disable CageFS for " %} {{ domainName }}. {% trans " CageFS require CloudLinux OS." %}
+
- SFTP Docs
+ {% trans "Set up SSH access for " %} {{ domainName }}.
+
-
+ SFTP Docs