-
-
Notifications
You must be signed in to change notification settings - Fork 713
Open
Description
Describe the bug
If you secure the ssh port, eg : xxxx, the web terminal use default port 22.
To Reproduce
- Change port SSH
- Use Web Terminal
Operating system:
Almalinux 10
CyberPanel version:
CyberPanel v2.4.4
To fix this : Edit /usr/local/CyberCP/fastapi_ssh_server.py
All Fix are commented.
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 = "-Irwra579-Yy2BTFVLnL5v1UD1tLKgNTzqMd_uNCoaA"
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"
AUTHORIZED_KEYS_PATH = f"/home/{SSH_USER}/.ssh/authorized_keys"
# FIX #1: Read the actual SSH port from sshd_config instead of always
# using asyncssh's default (port 22). Handles comments, inline comments,
# extra spaces, and case-insensitive 'Port' keyword.
def get_ssh_port() -> int:
try:
with open("/etc/ssh/sshd_config", "r") as f:
for line in f:
line = line.strip()
# Skip empty lines and full-line comments
if not line or line.startswith('#'):
continue
# Remove inline comments (e.g. "Port 2222 # custom")
line = line.split('#')[0].strip()
parts = line.split()
if len(parts) >= 2 and parts[0].lower() == 'port':
port = int(parts[1])
logging.info(f"[get_ssh_port] SSH port detected: {port}")
return port
except Exception as e:
logging.warning(f"[get_ssh_port] Could not read sshd_config: {e}")
# FIX #2: Fall back to 22 if parsing fails, with a warning in logs
logging.warning("[get_ssh_port] Falling back to default port 22")
return 22
# FIX #3: Read port once at startup so it's available for all connections
SSH_PORT = get_ssh_port()
# 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,::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,::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(
# FIX #4: Force IPv4 (127.0.0.1) instead of "localhost" which can
# resolve to ::1 (IPv6) and fail if IPv6 is disabled on the server
"127.0.0.1",
# FIX #5: Pass the detected SSH_PORT — this was the root cause:
# asyncssh defaults to port 22 when no port is specified, ignoring
# any custom port set in sshd_config
port=SSH_PORT,
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:
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:
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()@master3395 ... need fix into v2.5.5-dev 😄
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels